mirror of
https://github.com/kennethnym/freya
synced 2026-07-05 23:41:14 +01:00
feat: add agent response logging
This commit is contained in:
@@ -33,6 +33,8 @@
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"hono": "^4",
|
||||
"lodash.merge": "^4.6.2",
|
||||
"pino": "^10.3.1",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"typebox": "^1.1.38"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -10,11 +10,13 @@ import { type } from "arktype"
|
||||
|
||||
import type { ConversationStorage } from "../conversations/storage"
|
||||
import type { Job } from "../lib/job"
|
||||
import type { AppLogger } from "../lib/logger"
|
||||
import type { JobExecutor } from "../lib/worker"
|
||||
import type { NotificationCentral } from "../notification/notification-central"
|
||||
import type { UserSessionManager } from "../session"
|
||||
|
||||
import { ConversationResponseStateStatus } from "../db/schema"
|
||||
import { logger as rootLogger } from "../lib/logger"
|
||||
import {
|
||||
AgentResponseStreamEventKind,
|
||||
streamAgentResponse,
|
||||
@@ -27,61 +29,93 @@ export interface AgentResponseJobPayload {
|
||||
|
||||
interface AgentResponseWorkerConfig {
|
||||
conversationStorage: ConversationStorage
|
||||
logger?: AppLogger
|
||||
userSessionManager: UserSessionManager
|
||||
notificationCentral: NotificationCentral
|
||||
}
|
||||
|
||||
export class AgentResponseJobExecutor implements JobExecutor<AgentResponseJobPayload> {
|
||||
private conversationStorage: ConversationStorage
|
||||
private logger: AppLogger
|
||||
private userSessionManager: UserSessionManager
|
||||
private notificationCentral: NotificationCentral
|
||||
|
||||
constructor({
|
||||
conversationStorage,
|
||||
logger,
|
||||
userSessionManager,
|
||||
notificationCentral,
|
||||
}: AgentResponseWorkerConfig) {
|
||||
this.conversationStorage = conversationStorage
|
||||
this.logger = logger ?? rootLogger.child({ component: "agent_response_job" })
|
||||
this.userSessionManager = userSessionManager
|
||||
this.notificationCentral = notificationCentral
|
||||
}
|
||||
|
||||
async execute(job: Job<AgentResponseJobPayload>): Promise<void> {
|
||||
const conversation = await this.conversationStorage.findConversation(job.payload.conversationId)
|
||||
const conversationId = job.payload.conversationId
|
||||
const startedAt = Date.now()
|
||||
const logger = this.logger.child({ conversationId, jobId: job.id })
|
||||
|
||||
logger.info("agent response job started")
|
||||
|
||||
const conversation = await this.conversationStorage.findConversation(conversationId)
|
||||
if (!conversation) {
|
||||
logger.warn("conversation not found for agent response job")
|
||||
return
|
||||
}
|
||||
|
||||
const claimed = await this.conversationStorage.claimPendingConversationResponseState(
|
||||
job.payload.conversationId,
|
||||
)
|
||||
const claimed =
|
||||
await this.conversationStorage.claimPendingConversationResponseState(conversationId)
|
||||
if (!claimed) {
|
||||
// conversation response state not found or already claimed
|
||||
logger.debug("agent response state was not claimable")
|
||||
return
|
||||
}
|
||||
|
||||
const pendingEntries = await this.conversationStorage.listPendingUserConversationEntries(
|
||||
conversation.userId,
|
||||
conversation.id,
|
||||
)
|
||||
if (pendingEntries.length === 0) {
|
||||
await this.conversationStorage.clearConversationResponseState(job.payload.conversationId)
|
||||
return
|
||||
}
|
||||
|
||||
const message = pendingEntries.reduce((acc, entry) => {
|
||||
const payload = UserMessagePayload(entry.payload)
|
||||
if (payload instanceof type.errors) {
|
||||
return acc
|
||||
}
|
||||
return (
|
||||
acc + "\n" + payload.parts.reduce((msg, p) => (p.type === "text" ? msg + p.text : msg), "")
|
||||
)
|
||||
}, "")
|
||||
|
||||
const session = await this.userSessionManager.getOrCreate(conversation.userId)
|
||||
|
||||
try {
|
||||
const pendingEntries = await this.conversationStorage.listPendingUserConversationEntries(
|
||||
conversation.userId,
|
||||
conversation.id,
|
||||
)
|
||||
if (pendingEntries.length === 0) {
|
||||
await this.conversationStorage.clearConversationResponseState(conversationId)
|
||||
logger.debug("agent response state cleared with no pending user entries")
|
||||
return
|
||||
}
|
||||
|
||||
let invalidPendingEntryCount = 0
|
||||
const message = pendingEntries.reduce((acc, entry) => {
|
||||
const payload = UserMessagePayload(entry.payload)
|
||||
if (payload instanceof type.errors) {
|
||||
invalidPendingEntryCount += 1
|
||||
return acc
|
||||
}
|
||||
return (
|
||||
acc +
|
||||
"\n" +
|
||||
payload.parts.reduce((msg, p) => (p.type === "text" ? msg + p.text : msg), "")
|
||||
)
|
||||
}, "")
|
||||
|
||||
logger.debug(
|
||||
{
|
||||
invalidPendingEntryCount,
|
||||
messageLength: message.length,
|
||||
pendingEntryCount: pendingEntries.length,
|
||||
},
|
||||
"agent response pending user entries loaded",
|
||||
)
|
||||
|
||||
if (invalidPendingEntryCount > 0) {
|
||||
logger.warn(
|
||||
{ invalidPendingEntryCount },
|
||||
"invalid pending user entries skipped for agent response",
|
||||
)
|
||||
}
|
||||
|
||||
const session = await this.userSessionManager.getOrCreate(conversation.userId)
|
||||
|
||||
for await (const event of streamAgentResponse({
|
||||
agent: session.agent,
|
||||
input: {
|
||||
@@ -94,21 +128,36 @@ export class AgentResponseJobExecutor implements JobExecutor<AgentResponseJobPay
|
||||
break
|
||||
}
|
||||
|
||||
await this.handleStreamEvent(event, conversation.id, conversation.userId)
|
||||
await this.handleStreamEvent(event, conversation.id, conversation.userId, logger)
|
||||
}
|
||||
|
||||
// if job is aborted, stop everything immediately, including clean up.
|
||||
// the aborter is assumed responsibility on how to proceed.
|
||||
if (!job.signal.aborted) {
|
||||
await this.conversationStorage.clearConversationResponseState(job.payload.conversationId)
|
||||
await this.conversationStorage.clearConversationResponseState(conversationId)
|
||||
logger.info({ durationMs: Date.now() - startedAt }, "agent response job completed")
|
||||
} else {
|
||||
logger.warn({ durationMs: Date.now() - startedAt }, "agent response job aborted")
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[agent job executor] error streaming agent response:", err)
|
||||
if (!job.signal.aborted) {
|
||||
const durationMs = Date.now() - startedAt
|
||||
if (job.signal.aborted) {
|
||||
logger.warn({ durationMs, err }, "agent response job aborted")
|
||||
return
|
||||
}
|
||||
|
||||
logger.error({ durationMs, err }, "agent response job failed")
|
||||
try {
|
||||
await this.conversationStorage.markResponseStateStatus(
|
||||
[job.payload.conversationId],
|
||||
[conversationId],
|
||||
ConversationResponseStateStatus.Failed,
|
||||
)
|
||||
logger.warn(
|
||||
{ status: ConversationResponseStateStatus.Failed },
|
||||
"conversation response state marked failed",
|
||||
)
|
||||
} catch (markErr) {
|
||||
logger.error({ err: markErr }, "failed to mark conversation response state failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,6 +166,7 @@ export class AgentResponseJobExecutor implements JobExecutor<AgentResponseJobPay
|
||||
event: AgentResponseStreamEvent,
|
||||
conversationId: string,
|
||||
userId: string,
|
||||
logger: AppLogger,
|
||||
): Promise<void> {
|
||||
switch (event.kind) {
|
||||
case AgentResponseStreamEventKind.ConversationStarted:
|
||||
@@ -127,6 +177,13 @@ export class AgentResponseJobExecutor implements JobExecutor<AgentResponseJobPay
|
||||
conversationId: event.conversationId,
|
||||
},
|
||||
})
|
||||
logger.debug(
|
||||
{
|
||||
eventKind: event.kind,
|
||||
upstreamConversationId: event.conversationId,
|
||||
},
|
||||
"agent conversation started",
|
||||
)
|
||||
break
|
||||
|
||||
case AgentResponseStreamEventKind.AssistantMessage: {
|
||||
@@ -144,6 +201,14 @@ export class AgentResponseJobExecutor implements JobExecutor<AgentResponseJobPay
|
||||
entry,
|
||||
},
|
||||
})
|
||||
logger.debug(
|
||||
{
|
||||
entryId: entry.id,
|
||||
eventKind: event.kind,
|
||||
textLength: event.text.length,
|
||||
},
|
||||
"agent assistant message persisted",
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -161,6 +226,14 @@ export class AgentResponseJobExecutor implements JobExecutor<AgentResponseJobPay
|
||||
entry,
|
||||
},
|
||||
})
|
||||
logger.debug(
|
||||
{
|
||||
entryId: entry.id,
|
||||
eventKind: event.kind,
|
||||
toolName: event.toolName,
|
||||
},
|
||||
"agent tool call persisted",
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -179,6 +252,15 @@ export class AgentResponseJobExecutor implements JobExecutor<AgentResponseJobPay
|
||||
entry,
|
||||
},
|
||||
})
|
||||
logger.debug(
|
||||
{
|
||||
entryId: entry.id,
|
||||
eventKind: event.kind,
|
||||
ok: event.ok,
|
||||
toolName: event.toolName,
|
||||
},
|
||||
"agent tool result persisted",
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -190,6 +272,7 @@ export class AgentResponseJobExecutor implements JobExecutor<AgentResponseJobPay
|
||||
conversationId,
|
||||
},
|
||||
})
|
||||
logger.info({ eventKind: event.kind }, "agent response finished")
|
||||
break
|
||||
|
||||
case AgentResponseStreamEventKind.ResponseFailed:
|
||||
@@ -201,6 +284,10 @@ export class AgentResponseJobExecutor implements JobExecutor<AgentResponseJobPay
|
||||
error: event.error,
|
||||
},
|
||||
})
|
||||
logger.warn(
|
||||
{ agentError: event.error, eventKind: event.kind },
|
||||
"agent response failed event received",
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import type { ConversationStorage } from "../conversations/storage"
|
||||
import type { AppLogger } from "../lib/logger"
|
||||
import type { AgentWorkScheduler } from "./scheduler"
|
||||
|
||||
import { logger as rootLogger } from "../lib/logger"
|
||||
|
||||
interface AgentResponseReconcilerConfig {
|
||||
storage: ConversationStorage
|
||||
interval: number
|
||||
logger?: AppLogger
|
||||
scheduler: AgentWorkScheduler
|
||||
signal: AbortSignal
|
||||
}
|
||||
@@ -11,20 +15,23 @@ interface AgentResponseReconcilerConfig {
|
||||
export class AgentResponseReconciler {
|
||||
private storage: ConversationStorage
|
||||
private interval: number
|
||||
private logger: AppLogger
|
||||
private scheduler: AgentWorkScheduler
|
||||
private signal: AbortSignal
|
||||
|
||||
private stopLoop: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
constructor({ storage, interval, scheduler, signal }: AgentResponseReconcilerConfig) {
|
||||
constructor({ storage, interval, logger, scheduler, signal }: AgentResponseReconcilerConfig) {
|
||||
this.storage = storage
|
||||
this.interval = interval
|
||||
this.logger = logger ?? rootLogger.child({ component: "agent_reconciler" })
|
||||
this.scheduler = scheduler
|
||||
this.signal = signal
|
||||
}
|
||||
|
||||
start() {
|
||||
this.signal.throwIfAborted()
|
||||
this.logger.debug({ intervalMs: this.interval }, "agent response reconciler started")
|
||||
|
||||
this.signal.addEventListener(
|
||||
"abort",
|
||||
@@ -32,39 +39,62 @@ export class AgentResponseReconciler {
|
||||
if (this.stopLoop !== null) {
|
||||
clearInterval(this.stopLoop)
|
||||
this.stopLoop = null
|
||||
this.logger.debug("agent response reconciler stopped")
|
||||
}
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
|
||||
this.stopLoop = setInterval(this.reconcile.bind(this), this.interval)
|
||||
this.stopLoop = setInterval(() => {
|
||||
void this.reconcile()
|
||||
}, this.interval)
|
||||
}
|
||||
|
||||
private async reconcile() {
|
||||
// enqueue pending responses
|
||||
const pendingStates = await this.storage.listPendingResponseStates()
|
||||
const now = new Date().getTime()
|
||||
for (const state of pendingStates) {
|
||||
if (state.maxWaitUntil.getTime() < now) {
|
||||
this.scheduler.enqueueAgentResponse(state.conversationId)
|
||||
try {
|
||||
// enqueue pending responses
|
||||
const pendingStates = await this.storage.listPendingResponseStates()
|
||||
const now = new Date().getTime()
|
||||
this.logger.debug({ pendingCount: pendingStates.length }, "agent response reconcile tick")
|
||||
for (const state of pendingStates) {
|
||||
if (state.maxWaitUntil.getTime() < now) {
|
||||
this.logger.info(
|
||||
{
|
||||
conversationId: state.conversationId,
|
||||
maxWaitUntil: state.maxWaitUntil.toISOString(),
|
||||
},
|
||||
"pending agent response reached max wait",
|
||||
)
|
||||
this.scheduler.enqueueAgentResponse(state.conversationId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// re-enqueue stuck responses
|
||||
const runningStates = await this.storage.listRunningResponseStates()
|
||||
const stuckIds: string[] = []
|
||||
for (const state of runningStates) {
|
||||
if (state.runningSince && Math.max(now - state.runningSince.getTime(), 0) > 5 * 1000 * 60) {
|
||||
// if the response is running for more than 5 minutes
|
||||
// we assume that its stuck and enqueue it for retry
|
||||
stuckIds.push(state.conversationId)
|
||||
// re-enqueue stuck responses
|
||||
const runningStates = await this.storage.listRunningResponseStates()
|
||||
const stuckIds: string[] = []
|
||||
for (const state of runningStates) {
|
||||
if (state.runningSince && Math.max(now - state.runningSince.getTime(), 0) > 5 * 1000 * 60) {
|
||||
// if the response is running for more than 5 minutes
|
||||
// we assume that its stuck and enqueue it for retry
|
||||
stuckIds.push(state.conversationId)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (stuckIds.length > 0) {
|
||||
await this.storage.markResponseStateStatus(stuckIds, "pending")
|
||||
for (const id of stuckIds) {
|
||||
this.scheduler.enqueueAgentResponse(id)
|
||||
this.logger.debug(
|
||||
{ runningCount: runningStates.length, stuckCount: stuckIds.length },
|
||||
"agent response running states reconciled",
|
||||
)
|
||||
if (stuckIds.length > 0) {
|
||||
await this.storage.markResponseStateStatus(stuckIds, "pending")
|
||||
this.logger.warn(
|
||||
{ conversationIds: stuckIds, count: stuckIds.length },
|
||||
"stuck agent responses requeued",
|
||||
)
|
||||
for (const id of stuckIds) {
|
||||
this.scheduler.enqueueAgentResponse(id)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error({ err }, "agent response reconciliation failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,19 @@ import type { UserEvent } from "@freya/agent-protocol"
|
||||
|
||||
import type { ConversationStorage } from "../conversations/storage"
|
||||
import type { Job, JobRegistry } from "../lib/job"
|
||||
import type { AppLogger } from "../lib/logger"
|
||||
import type { AgentResponseJobPayload } from "./job"
|
||||
|
||||
import { ConversationNotFoundError } from "../conversations/errors"
|
||||
import { ConversationResponseStateStatus } from "../db/schema"
|
||||
import { logger as rootLogger } from "../lib/logger"
|
||||
|
||||
const AgentJobCancellationReason = {
|
||||
NewUserActivity: "new_user_activity",
|
||||
SupersededByEnqueue: "superseded_by_enqueue",
|
||||
} as const
|
||||
type AgentJobCancellationReason =
|
||||
(typeof AgentJobCancellationReason)[keyof typeof AgentJobCancellationReason]
|
||||
|
||||
interface AgentMessageSchedulerConfig {
|
||||
storage: ConversationStorage
|
||||
@@ -14,9 +23,10 @@ interface AgentMessageSchedulerConfig {
|
||||
/**
|
||||
* How long to wait before responding to the user.
|
||||
*/
|
||||
waitTIme: number
|
||||
waitTime: number
|
||||
|
||||
jobRegistry: JobRegistry<AgentResponseJobPayload>
|
||||
logger?: AppLogger
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,6 +35,7 @@ interface AgentMessageSchedulerConfig {
|
||||
export class AgentWorkScheduler {
|
||||
private conversationStorage: ConversationStorage
|
||||
private jobRegistry: JobRegistry<AgentResponseJobPayload>
|
||||
private logger: AppLogger
|
||||
|
||||
private timing: {
|
||||
maxWaitTime: number
|
||||
@@ -37,9 +48,10 @@ export class AgentWorkScheduler {
|
||||
constructor(config: AgentMessageSchedulerConfig) {
|
||||
this.conversationStorage = config.storage
|
||||
this.jobRegistry = config.jobRegistry
|
||||
this.logger = config.logger ?? rootLogger.child({ component: "agent_scheduler" })
|
||||
this.timing = {
|
||||
maxWaitTime: config.maxWaitTime,
|
||||
waitTime: config.waitTIme,
|
||||
waitTime: config.waitTime,
|
||||
}
|
||||
|
||||
this.jobRegistry.addEventListener("settled", this.eraseJob.bind(this))
|
||||
@@ -50,9 +62,10 @@ export class AgentWorkScheduler {
|
||||
const existing = this.timers.get(conversationId)
|
||||
if (existing) {
|
||||
clearTimeout(existing)
|
||||
this.logger.debug({ conversationId }, "existing agent response timer replaced")
|
||||
}
|
||||
|
||||
this.cancelCurrentJob(conversationId)
|
||||
this.cancelCurrentJob(conversationId, AgentJobCancellationReason.NewUserActivity)
|
||||
|
||||
this.timers.set(
|
||||
conversationId,
|
||||
@@ -60,11 +73,22 @@ export class AgentWorkScheduler {
|
||||
this.enqueueAgentResponse(conversationId)
|
||||
}, this.timing.waitTime),
|
||||
)
|
||||
|
||||
this.logger.info(
|
||||
{
|
||||
conversationId,
|
||||
maxWaitMs: this.timing.maxWaitTime,
|
||||
waitMs: this.timing.waitTime,
|
||||
},
|
||||
"agent response scheduled",
|
||||
)
|
||||
}
|
||||
|
||||
async receiveUserEvent(conversationId: string, event: UserEvent) {
|
||||
if (event.type === "typing") {
|
||||
this.logger.debug({ conversationId, eventType: event.type }, "user event received")
|
||||
await this.delayAgentResponse(conversationId)
|
||||
this.logger.debug({ conversationId }, "agent response delay handled")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,18 +97,20 @@ export class AgentWorkScheduler {
|
||||
if (existing) {
|
||||
clearTimeout(existing)
|
||||
this.timers.delete(conversationId)
|
||||
this.logger.debug({ conversationId }, "agent response timer consumed")
|
||||
}
|
||||
|
||||
this.cancelCurrentJob(conversationId)
|
||||
this.cancelCurrentJob(conversationId, AgentJobCancellationReason.SupersededByEnqueue)
|
||||
|
||||
const job = this.jobRegistry.addJob({
|
||||
payload: { conversationId },
|
||||
})
|
||||
this.runningJobs.set(conversationId, job)
|
||||
this.logger.info({ conversationId, jobId: job.id }, "agent response job enqueued")
|
||||
}
|
||||
|
||||
private async delayAgentResponse(conversationId: string) {
|
||||
this.cancelCurrentJob(conversationId)
|
||||
this.cancelCurrentJob(conversationId, AgentJobCancellationReason.NewUserActivity)
|
||||
|
||||
try {
|
||||
const ok = await this.conversationStorage.transaction(async (storage) => {
|
||||
@@ -102,14 +128,20 @@ export class AgentWorkScheduler {
|
||||
})
|
||||
if (ok) {
|
||||
await this.scheduleAgentResponse(conversationId)
|
||||
} else {
|
||||
this.logger.debug(
|
||||
{ conversationId },
|
||||
"agent response delay skipped because response state is not pending",
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ConversationNotFoundError) {
|
||||
// the user is typing but there isn't a scheduled agent response yet
|
||||
// which means the user is typing their first message after the agent has previously responded
|
||||
// swallow the error
|
||||
this.logger.debug({ conversationId }, "typing event received without active conversation")
|
||||
} else {
|
||||
console.error("[agent response scheduler] error delaying agent response", error)
|
||||
this.logger.error({ err: error, conversationId }, "agent response delay failed")
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -119,18 +151,26 @@ export class AgentWorkScheduler {
|
||||
* cancels the current job for agent response for the given conversation id
|
||||
* no-op if there is no active job for the conversation.
|
||||
*/
|
||||
private cancelCurrentJob(conversationId: string): void {
|
||||
private cancelCurrentJob(conversationId: string, reason: AgentJobCancellationReason): void {
|
||||
const job = this.runningJobs.get(conversationId)
|
||||
if (!job) return
|
||||
|
||||
// If an active response is working on stale context, abort it so the next
|
||||
// job can answer using the latest pending user messages.
|
||||
this.jobRegistry.cancelJob(job)
|
||||
this.logger.info(
|
||||
{ conversationId, jobId: job.id, reason },
|
||||
"active agent response job cancelled",
|
||||
)
|
||||
}
|
||||
|
||||
private eraseJob(job: Job<AgentResponseJobPayload>) {
|
||||
if (this.runningJobs.get(job.payload.conversationId) === job) {
|
||||
this.runningJobs.delete(job.payload.conversationId)
|
||||
this.logger.debug(
|
||||
{ conversationId: job.payload.conversationId, jobId: job.id },
|
||||
"agent response job tracking cleared",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ import type { UserEvent } from "@freya/agent-protocol"
|
||||
import { ConversationEntry, ConversationEntryKind, UserMessagePayload } from "@freya/core"
|
||||
|
||||
import type { ConversationStorage } from "../conversations/storage"
|
||||
import type { AppLogger } from "../lib/logger"
|
||||
import type { NotificationCentral } from "../notification/notification-central"
|
||||
import type { UserSessionManager } from "../session"
|
||||
|
||||
import { JobRegistry } from "../lib/job"
|
||||
import { logger as rootLogger } from "../lib/logger"
|
||||
import { Worker } from "../lib/worker"
|
||||
import { AgentResponseJobExecutor, type AgentResponseJobPayload } from "./job"
|
||||
import { AgentResponseReconciler } from "./reconciler"
|
||||
@@ -22,74 +24,129 @@ interface AgentServiceConfig {
|
||||
userSessionManager: UserSessionManager
|
||||
notificationCentral: NotificationCentral
|
||||
signal: AbortSignal
|
||||
logger?: AppLogger
|
||||
}
|
||||
|
||||
export class AgentService {
|
||||
private readonly storage: ConversationStorage
|
||||
private readonly logger: AppLogger
|
||||
private readonly scheduler: AgentWorkScheduler
|
||||
private readonly reconciler: AgentResponseReconciler
|
||||
private readonly worker: Worker<AgentResponseJobPayload>
|
||||
|
||||
private readonly jobRegistry = new JobRegistry<AgentResponseJobPayload>()
|
||||
|
||||
constructor({ storage, userSessionManager, notificationCentral, signal }: AgentServiceConfig) {
|
||||
constructor({
|
||||
storage,
|
||||
userSessionManager,
|
||||
notificationCentral,
|
||||
signal,
|
||||
logger,
|
||||
}: AgentServiceConfig) {
|
||||
const baseLogger = logger ?? rootLogger
|
||||
|
||||
this.storage = storage
|
||||
this.logger = baseLogger.child({ component: "agent_service" })
|
||||
this.scheduler = new AgentWorkScheduler({
|
||||
storage,
|
||||
jobRegistry: this.jobRegistry,
|
||||
waitTIme: AgentResponseTiming.waitTime,
|
||||
waitTime: AgentResponseTiming.waitTime,
|
||||
maxWaitTime: AgentResponseTiming.maxWaitTime,
|
||||
logger: baseLogger.child({ component: "agent_scheduler" }),
|
||||
})
|
||||
this.reconciler = new AgentResponseReconciler({
|
||||
signal,
|
||||
storage: this.storage,
|
||||
interval: 60 * 1000,
|
||||
scheduler: this.scheduler,
|
||||
logger: baseLogger.child({ component: "agent_reconciler" }),
|
||||
})
|
||||
this.worker = new Worker<AgentResponseJobPayload>({
|
||||
signal,
|
||||
concurrency: 10,
|
||||
registry: this.jobRegistry,
|
||||
logger: baseLogger.child({ component: "agent_worker" }),
|
||||
jobFields: agentResponseJobFields,
|
||||
runner: new AgentResponseJobExecutor({
|
||||
conversationStorage: storage,
|
||||
notificationCentral,
|
||||
userSessionManager,
|
||||
logger: baseLogger.child({ component: "agent_response_job" }),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
start() {
|
||||
this.logger.info("agent service starting")
|
||||
this.worker.start()
|
||||
this.reconciler.start()
|
||||
}
|
||||
|
||||
async scheduleAgentResponse(conversationId: string, message: string): Promise<ConversationEntry> {
|
||||
const createdEntry = await this.storage.transaction(async (storage) => {
|
||||
const now = new Date()
|
||||
this.logger.info(
|
||||
{ conversationId, messageLength: message.length },
|
||||
"scheduling agent response from user message",
|
||||
)
|
||||
|
||||
const entry = await storage.appendEntry(conversationId, {
|
||||
kind: ConversationEntryKind.UserMessage,
|
||||
payload: {
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: message }],
|
||||
} satisfies UserMessagePayload,
|
||||
try {
|
||||
const scheduledResponse = await this.storage.transaction(async (storage) => {
|
||||
const now = new Date()
|
||||
const maxWaitUntil = new Date(now.getTime() + AgentResponseTiming.maxWaitTime)
|
||||
|
||||
const entry = await storage.appendEntry(conversationId, {
|
||||
kind: ConversationEntryKind.UserMessage,
|
||||
payload: {
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: message }],
|
||||
} satisfies UserMessagePayload,
|
||||
})
|
||||
|
||||
await storage.upsertConversationResponseState(conversationId, {
|
||||
maxWaitUntil,
|
||||
pendingSinceEntryId: entry.id,
|
||||
status: "pending",
|
||||
})
|
||||
|
||||
return { entry, maxWaitUntil }
|
||||
})
|
||||
|
||||
await storage.upsertConversationResponseState(conversationId, {
|
||||
maxWaitUntil: new Date(now.getTime() + AgentResponseTiming.maxWaitTime),
|
||||
pendingSinceEntryId: entry.id,
|
||||
status: "pending",
|
||||
})
|
||||
this.logger.info(
|
||||
{
|
||||
conversationId,
|
||||
entryId: scheduledResponse.entry.id,
|
||||
maxWaitMs: AgentResponseTiming.maxWaitTime,
|
||||
maxWaitUntil: scheduledResponse.maxWaitUntil.toISOString(),
|
||||
waitMs: AgentResponseTiming.waitTime,
|
||||
},
|
||||
"agent response state set pending",
|
||||
)
|
||||
|
||||
return entry
|
||||
})
|
||||
await this.scheduler.scheduleAgentResponse(conversationId)
|
||||
|
||||
await this.scheduler.scheduleAgentResponse(conversationId)
|
||||
|
||||
return createdEntry
|
||||
return scheduledResponse.entry
|
||||
} catch (err) {
|
||||
this.logger.error({ err, conversationId }, "failed to schedule agent response")
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async handleUserEvent(conversationId: string, event: UserEvent) {
|
||||
await this.scheduler.receiveUserEvent(conversationId, event)
|
||||
try {
|
||||
await this.scheduler.receiveUserEvent(conversationId, event)
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
{ err, conversationId, eventType: event.type },
|
||||
"failed to handle user event",
|
||||
)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function agentResponseJobFields(job: {
|
||||
payload: AgentResponseJobPayload
|
||||
}): Record<string, unknown> {
|
||||
return {
|
||||
conversationId: job.payload.conversationId,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,17 @@ import { upgradeWebSocket, websocket } from "hono/bun"
|
||||
|
||||
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
|
||||
import type { ConversationStorage } from "../conversations/storage.ts"
|
||||
import type { AppLogger } from "../lib/logger.ts"
|
||||
import type {
|
||||
NotificationCentral,
|
||||
NotificationPayload,
|
||||
} from "../notification/notification-central.ts"
|
||||
import type { AgentService } from "./service.ts"
|
||||
|
||||
import { logger } from "../lib/logger.ts"
|
||||
|
||||
const agentWebSocketLogger = logger.child({ component: "agent_ws" })
|
||||
|
||||
interface AgentWebSocketHandlerDeps {
|
||||
agentService: AgentService
|
||||
storage: ConversationStorage
|
||||
@@ -60,7 +65,10 @@ export function registerAgentWebSocketHandlers(
|
||||
onOpen(_event, ws) {
|
||||
channel.attach(ws)
|
||||
void connection.start().catch((err: unknown) => {
|
||||
console.error("[query] Agent WebSocket JSON-RPC failed:", errorMessage(err))
|
||||
agentWebSocketLogger.error(
|
||||
{ err, conversationId: conversation.id },
|
||||
"agent websocket JSON-RPC failed",
|
||||
)
|
||||
ws.close(1011, "Agent RPC connection failed")
|
||||
})
|
||||
},
|
||||
@@ -82,6 +90,7 @@ class AgentRpcConnection implements AgentServerApi {
|
||||
private readonly client: JsonRpcClient<AgentClientApi>
|
||||
private readonly server: JsonRpcServer<AgentServerApi>
|
||||
private readonly agentService: AgentService
|
||||
private readonly logger: AppLogger
|
||||
private readonly notificationCentral: NotificationCentral
|
||||
private readonly userId: string
|
||||
private readonly conversationId: string
|
||||
@@ -103,6 +112,7 @@ class AgentRpcConnection implements AgentServerApi {
|
||||
}) {
|
||||
this.client = new JsonRpcClient<AgentClientApi>(channel)
|
||||
this.agentService = agentService
|
||||
this.logger = agentWebSocketLogger.child({ conversationId })
|
||||
this.notificationCentral = notificationCentral
|
||||
this.userId = userId
|
||||
this.conversationId = conversationId
|
||||
@@ -117,14 +127,19 @@ class AgentRpcConnection implements AgentServerApi {
|
||||
}
|
||||
|
||||
notify(event: UserEvent): void {
|
||||
this.agentService.handleUserEvent(this.conversationId, event)
|
||||
void this.agentService.handleUserEvent(this.conversationId, event).catch((err: unknown) => {
|
||||
this.logger.error({ err, eventType: event.type }, "failed to handle agent user event")
|
||||
})
|
||||
}
|
||||
|
||||
async sendMessage(message: string): Promise<ConversationEntry> {
|
||||
try {
|
||||
return await this.agentService.scheduleAgentResponse(this.conversationId, message)
|
||||
} catch (error) {
|
||||
console.error("[agent rpc connection] error when scheduling agent response", error)
|
||||
this.logger.error(
|
||||
{ err: error, messageLength: message.length },
|
||||
"agent rpc schedule agent response failed",
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -239,7 +254,3 @@ class HonoWebSocketJrpcChannel implements JrpcChannel {
|
||||
this.queue.push(msg)
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
83
apps/freya-backend/src/lib/logger.ts
Normal file
83
apps/freya-backend/src/lib/logger.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { hostname } from "node:os"
|
||||
import pino, { type Logger } from "pino"
|
||||
|
||||
export type AppLogger = Logger
|
||||
|
||||
const LogLevel = {
|
||||
Trace: "trace",
|
||||
Debug: "debug",
|
||||
Info: "info",
|
||||
Warn: "warn",
|
||||
Error: "error",
|
||||
Fatal: "fatal",
|
||||
Silent: "silent",
|
||||
} as const
|
||||
type LogLevel = (typeof LogLevel)[keyof typeof LogLevel]
|
||||
|
||||
export const logger = pino({
|
||||
base: {
|
||||
hostname: hostname(),
|
||||
pid: process.pid,
|
||||
service: "freya-backend",
|
||||
},
|
||||
level: readLogLevel(process.env.LOG_LEVEL, process.env.NODE_ENV),
|
||||
transport:
|
||||
readLogFormat(process.env.LOG_FORMAT, process.env.NODE_ENV) === "pretty"
|
||||
? {
|
||||
target: "pino-pretty",
|
||||
options: {
|
||||
colorize: true,
|
||||
ignore: "hostname,pid",
|
||||
singleLine: false,
|
||||
translateTime: "SYS:standard",
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
redact: {
|
||||
censor: "[redacted]",
|
||||
paths: [
|
||||
"apiKey",
|
||||
"authorization",
|
||||
"cookie",
|
||||
"credentialEncryptionKey",
|
||||
"password",
|
||||
"privateKey",
|
||||
"*.apiKey",
|
||||
"*.authorization",
|
||||
"*.cookie",
|
||||
"*.credentialEncryptionKey",
|
||||
"*.password",
|
||||
"*.privateKey",
|
||||
],
|
||||
},
|
||||
serializers: {
|
||||
err: pino.stdSerializers.err,
|
||||
},
|
||||
})
|
||||
|
||||
function readLogFormat(value: string | undefined, nodeEnv: string | undefined): "json" | "pretty" {
|
||||
const normalized = value?.trim().toLowerCase()
|
||||
if (normalized === "json") {
|
||||
return "json"
|
||||
}
|
||||
if (nodeEnv === "test") {
|
||||
return "json"
|
||||
}
|
||||
return "pretty"
|
||||
}
|
||||
|
||||
function readLogLevel(value: string | undefined, nodeEnv: string | undefined): LogLevel {
|
||||
const normalized = value?.trim().toLowerCase()
|
||||
switch (normalized) {
|
||||
case LogLevel.Trace:
|
||||
case LogLevel.Debug:
|
||||
case LogLevel.Info:
|
||||
case LogLevel.Warn:
|
||||
case LogLevel.Error:
|
||||
case LogLevel.Fatal:
|
||||
case LogLevel.Silent:
|
||||
return normalized
|
||||
default:
|
||||
return nodeEnv === "test" ? LogLevel.Silent : LogLevel.Info
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,40 @@
|
||||
import type { Job, JobRegistry } from "./job"
|
||||
import type { Queue } from "./queue"
|
||||
import type { AppLogger } from "./logger"
|
||||
|
||||
import { logger as rootLogger } from "./logger"
|
||||
|
||||
export interface JobExecutor<JobPayload> {
|
||||
execute(job: Job<JobPayload>): Promise<void>
|
||||
}
|
||||
|
||||
export interface WorkerConfig<Job> {
|
||||
export interface WorkerConfig<JobPayload> {
|
||||
concurrency: number
|
||||
registry: JobRegistry<Job>
|
||||
runner: JobExecutor<Job>
|
||||
jobFields?: (job: Job<JobPayload>) => Record<string, unknown>
|
||||
logger?: AppLogger
|
||||
registry: JobRegistry<JobPayload>
|
||||
runner: JobExecutor<JobPayload>
|
||||
signal: AbortSignal
|
||||
}
|
||||
|
||||
export class Worker<Job> {
|
||||
export class Worker<JobPayload> {
|
||||
private concurrency: number
|
||||
private registry: JobRegistry<Job>
|
||||
private runner: JobExecutor<Job>
|
||||
private jobFields: (job: Job<JobPayload>) => Record<string, unknown>
|
||||
private logger: AppLogger
|
||||
private registry: JobRegistry<JobPayload>
|
||||
private runner: JobExecutor<JobPayload>
|
||||
private signal: AbortSignal
|
||||
|
||||
constructor({ concurrency, registry, runner, signal }: WorkerConfig<Job>) {
|
||||
constructor({
|
||||
concurrency,
|
||||
jobFields,
|
||||
logger,
|
||||
registry,
|
||||
runner,
|
||||
signal,
|
||||
}: WorkerConfig<JobPayload>) {
|
||||
this.concurrency = concurrency
|
||||
this.jobFields = jobFields ?? emptyJobFields
|
||||
this.logger = logger ?? rootLogger.child({ component: "worker" })
|
||||
this.registry = registry
|
||||
this.runner = runner
|
||||
this.signal = signal
|
||||
@@ -27,25 +42,45 @@ export class Worker<Job> {
|
||||
|
||||
start() {
|
||||
if (this.signal.aborted) return
|
||||
this.logger.debug({ concurrency: this.concurrency }, "worker started")
|
||||
for (let i = 0; i < this.concurrency; i++) {
|
||||
void this.pollJobFromRegistry()
|
||||
void this.pollJobFromRegistry(i)
|
||||
}
|
||||
}
|
||||
|
||||
private async pollJobFromRegistry() {
|
||||
private async pollJobFromRegistry(workerId: number) {
|
||||
while (!this.signal.aborted) {
|
||||
const job = await this.registry.nextJob(this.signal)
|
||||
if (!job) {
|
||||
return
|
||||
}
|
||||
|
||||
const startedAt = Date.now()
|
||||
const fields = {
|
||||
...this.jobFields(job),
|
||||
jobId: job.id,
|
||||
workerId,
|
||||
}
|
||||
this.logger.debug(fields, "job execution started")
|
||||
|
||||
try {
|
||||
await this.runner.execute(job)
|
||||
} catch {
|
||||
// TODO: handle logging of job execution errors
|
||||
this.logger.debug(
|
||||
{ ...fields, durationMs: Date.now() - startedAt },
|
||||
"job execution completed",
|
||||
)
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
{ ...fields, durationMs: Date.now() - startedAt, err },
|
||||
"job execution failed",
|
||||
)
|
||||
} finally {
|
||||
this.registry.markJobAsCompleted(job)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function emptyJobFields<JobPayload>(_job: Job<JobPayload>): Record<string, unknown> {
|
||||
return {}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { createLlmClient } from "./enhancement/llm-client.ts"
|
||||
import { GoogleMapsSourceProvider } from "./google-maps/provider.ts"
|
||||
import { CredentialEncryptor } from "./lib/crypto.ts"
|
||||
import { ensureEnv } from "./lib/env.ts"
|
||||
import { logger } from "./lib/logger.ts"
|
||||
import { registerLocationHttpHandlers } from "./location/http.ts"
|
||||
import { LocationSourceProvider } from "./location/provider.ts"
|
||||
import { NotificationCentral } from "./notification/notification-central.ts"
|
||||
@@ -77,7 +78,10 @@ function main() {
|
||||
},
|
||||
})
|
||||
if (!piApiKey) {
|
||||
console.warn("[query] PI_API_KEY or OPENROUTER_API_KEY not set — query agent unavailable")
|
||||
logger.warn(
|
||||
{ component: "query_agent" },
|
||||
"PI_API_KEY or OPENROUTER_API_KEY not set; query agent unavailable",
|
||||
)
|
||||
}
|
||||
|
||||
const notificationCentral = new NotificationCentral()
|
||||
@@ -87,6 +91,7 @@ function main() {
|
||||
storage: conversationStorage,
|
||||
userSessionManager: sessionManager,
|
||||
signal: abortController.signal,
|
||||
logger,
|
||||
})
|
||||
|
||||
const app = new Hono()
|
||||
|
||||
Reference in New Issue
Block a user