chore: auto compact chat
This commit is contained in:
@@ -63,7 +63,6 @@ import { eq } from 'drizzle-orm';
|
||||
import { listCourseSections } from '@api/services/course/section';
|
||||
import { getLesson } from '@api/services/lesson/lesson';
|
||||
import { getExercise } from '@api/services/exercise/exercise';
|
||||
import { trimMessageHistory } from '@api/services/agent/context-window';
|
||||
import { sanitizeDanglingToolCalls } from '@api/services/agent/sanitize-tool-calls';
|
||||
import {
|
||||
collectDocumentIds,
|
||||
@@ -74,8 +73,10 @@ import {
|
||||
verifyLessonBelongsToCourse
|
||||
} from '@api/services/agent/chat-context';
|
||||
import { buildAgentTools } from '@api/services/agent/chat-tools';
|
||||
import { buildModelContextMessages } from '@api/services/agent/model-context';
|
||||
import { summarizeConversation } from '@api/services/agent/summarize';
|
||||
import { agentHistoryRouter } from './history';
|
||||
import { agentRunsRouter } from './runs';
|
||||
|
||||
const agentCoreRouter = new Hono()
|
||||
.get('/status', authMiddleware, orgMemberMiddleware, zValidator('query', ZAgentStatusQuery), async (c) => {
|
||||
@@ -385,7 +386,7 @@ const agentCoreRouter = new Hono()
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
|
||||
try {
|
||||
const { courseId, messages, context, model: requestedModel } = c.req.valid('json');
|
||||
const { courseId, conversationId, messages, context, model: requestedModel } = c.req.valid('json');
|
||||
|
||||
const isTeamMember = await isCourseTeamMemberOrOrgAdmin(courseId, user.id);
|
||||
const role = isTeamMember ? AgentRole.TEACHER : AgentRole.STUDENT;
|
||||
@@ -423,6 +424,14 @@ const agentCoreRouter = new Hono()
|
||||
throw new AppError('Course does not belong to this organization', 'COURSE_ORG_MISMATCH', 403);
|
||||
}
|
||||
|
||||
if (conversationId) {
|
||||
const conversation = await getChatConversation(conversationId, user.id);
|
||||
|
||||
if (!conversation || conversation.courseId !== courseId) {
|
||||
throw new AppError('Conversation not found for this course', 'CONVERSATION_NOT_FOUND', 404);
|
||||
}
|
||||
}
|
||||
|
||||
if (role === AgentRole.TEACHER && !modelDescriptor.isFree) {
|
||||
const isPaid = await isOrgOnPaidPlan(orgId);
|
||||
|
||||
@@ -524,8 +533,13 @@ const agentCoreRouter = new Hono()
|
||||
? buildStudentAgentTools(orgId, user.id, courseId, studentPolicy!.settings)
|
||||
: buildAgentTools(orgId, user.id, courseId, messages);
|
||||
|
||||
const trimmedMessages = trimMessageHistory(messages);
|
||||
const convertedMessages = sanitizeDanglingToolCalls(await convertToModelMessages(trimmedMessages));
|
||||
const contextManaged = await buildModelContextMessages({
|
||||
conversationId,
|
||||
courseId,
|
||||
userId: user.id,
|
||||
messages
|
||||
});
|
||||
const convertedMessages = sanitizeDanglingToolCalls(await convertToModelMessages(contextManaged.messages as any));
|
||||
let completedStepCount = 0;
|
||||
let finishReason: string | undefined;
|
||||
|
||||
@@ -695,4 +709,5 @@ const agentCoreRouter = new Hono()
|
||||
export const agentRouter = new Hono()
|
||||
.use('*', agentContentTypeRewrite)
|
||||
.route('/', agentCoreRouter)
|
||||
.route('/history', agentHistoryRouter);
|
||||
.route('/history', agentHistoryRouter)
|
||||
.route('/runs', agentRunsRouter);
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { authMiddleware } from '@api/middlewares/auth';
|
||||
import { orgMemberMiddleware } from '@api/middlewares/org-member';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import {
|
||||
ZAgentRunCourseQuery,
|
||||
ZAgentRunCreateBody,
|
||||
ZAgentRunInstructionBody,
|
||||
ZAgentRunParam
|
||||
} from '@cio/utils/validation/agent';
|
||||
import { handleError } from '@api/utils/errors';
|
||||
import {
|
||||
appendAgentRunInstructionState,
|
||||
cancelAgentRunState,
|
||||
createAgentRunState,
|
||||
getAgentRunState,
|
||||
listAgentRunStates,
|
||||
resumeAgentRunState,
|
||||
retryAgentRunState
|
||||
} from '@api/services/agent/run-state';
|
||||
|
||||
export const agentRunsRouter = new Hono()
|
||||
/**
|
||||
* GET /agent/runs?courseId=...
|
||||
* List durable course-generation runs for the current user and course.
|
||||
*/
|
||||
.get('/', authMiddleware, orgMemberMiddleware, zValidator('query', ZAgentRunCourseQuery), async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const { courseId } = c.req.valid('query');
|
||||
const runs = await listAgentRunStates(courseId, user.id, orgId);
|
||||
|
||||
return c.json({ success: true as const, data: runs });
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to list agent runs');
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /agent/runs
|
||||
* Create a durable run record before worker-backed generation begins.
|
||||
*/
|
||||
.post('/', authMiddleware, orgMemberMiddleware, zValidator('json', ZAgentRunCreateBody), async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const data = c.req.valid('json');
|
||||
const run = await createAgentRunState({
|
||||
orgId,
|
||||
userId: user.id,
|
||||
courseId: data.courseId,
|
||||
conversationId: data.conversationId,
|
||||
phase: data.phase,
|
||||
approvedPlan: data.approvedPlan,
|
||||
executionCursor: data.executionCursor,
|
||||
sourceIds: data.sourceIds,
|
||||
modelSummary: data.modelSummary
|
||||
});
|
||||
|
||||
return c.json({ success: true as const, data: run }, 201);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to create agent run');
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /agent/runs/:runId
|
||||
* Fetch the run, checkpoint steps, and progress events.
|
||||
*/
|
||||
.get('/:runId', authMiddleware, orgMemberMiddleware, zValidator('param', ZAgentRunParam), async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const { runId } = c.req.valid('param');
|
||||
const run = await getAgentRunState(runId, user.id, orgId);
|
||||
|
||||
return c.json({ success: true as const, data: run });
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to fetch agent run');
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /agent/runs/:runId/resume
|
||||
* Requeue a paused or waiting run from its saved cursor.
|
||||
*/
|
||||
.post('/:runId/resume', authMiddleware, orgMemberMiddleware, zValidator('param', ZAgentRunParam), async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const { runId } = c.req.valid('param');
|
||||
const run = await resumeAgentRunState(runId, user.id, orgId);
|
||||
|
||||
return c.json({ success: true as const, data: run });
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to resume agent run');
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /agent/runs/:runId/retry
|
||||
* Requeue a failed run without duplicating completed checkpointed steps.
|
||||
*/
|
||||
.post('/:runId/retry', authMiddleware, orgMemberMiddleware, zValidator('param', ZAgentRunParam), async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const { runId } = c.req.valid('param');
|
||||
const run = await retryAgentRunState(runId, user.id, orgId);
|
||||
|
||||
return c.json({ success: true as const, data: run });
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to retry agent run');
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /agent/runs/:runId/cancel
|
||||
* Mark the run canceled so workers stop before the next step.
|
||||
*/
|
||||
.post('/:runId/cancel', authMiddleware, orgMemberMiddleware, zValidator('param', ZAgentRunParam), async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const { runId } = c.req.valid('param');
|
||||
const run = await cancelAgentRunState(runId, user.id, orgId);
|
||||
|
||||
return c.json({ success: true as const, data: run });
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to cancel agent run');
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /agent/runs/:runId/instructions
|
||||
* Queue teacher instructions for the worker to consume between steps.
|
||||
*/
|
||||
.post(
|
||||
'/:runId/instructions',
|
||||
authMiddleware,
|
||||
orgMemberMiddleware,
|
||||
zValidator('param', ZAgentRunParam),
|
||||
zValidator('json', ZAgentRunInstructionBody),
|
||||
async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const { runId } = c.req.valid('param');
|
||||
const { text } = c.req.valid('json');
|
||||
const run = await appendAgentRunInstructionState(runId, user.id, orgId, text);
|
||||
|
||||
return c.json({ success: true as const, data: run });
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to queue agent run instruction');
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,108 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { getChatModelContext, upsertChatModelContext } from '@cio/db/queries/agent';
|
||||
import { trimMessageHistory } from '@api/services/agent/context-window';
|
||||
import { summarizeConversationDelta, type SummarizeOptions } from '@api/services/agent/summarize';
|
||||
|
||||
const KEEP_RECENT_MESSAGES = 16;
|
||||
const MIN_MESSAGES_BEFORE_SUMMARY = KEEP_RECENT_MESSAGES + 4;
|
||||
|
||||
type AgentMessage = SummarizeOptions['messages'][number] & {
|
||||
id?: string;
|
||||
metadata?: Record<string, unknown> | null | undefined;
|
||||
};
|
||||
|
||||
export interface BuildModelContextMessagesOptions {
|
||||
conversationId?: string;
|
||||
courseId: string;
|
||||
userId: string;
|
||||
messages: AgentMessage[];
|
||||
}
|
||||
|
||||
export interface BuildModelContextMessagesResult {
|
||||
messages: AgentMessage[];
|
||||
summarized: boolean;
|
||||
compactedThroughMessageId?: string | null;
|
||||
}
|
||||
|
||||
function getMessageId(message: AgentMessage | undefined): string | null {
|
||||
return typeof message?.id === 'string' && message.id.length > 0 ? message.id : null;
|
||||
}
|
||||
|
||||
function buildSummarySeed(summary: string, compactedThroughMessageId?: string | null): AgentMessage {
|
||||
return {
|
||||
id: `model-context-summary-${randomUUID()}`,
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Conversation summary for continuity. Use this as prior context, then continue from the recent messages that follow.\n\n${summary}`
|
||||
}
|
||||
],
|
||||
metadata: {
|
||||
modelContextSummary: {
|
||||
compactedThroughMessageId: compactedThroughMessageId ?? null
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildModelContextMessages(
|
||||
options: BuildModelContextMessagesOptions
|
||||
): Promise<BuildModelContextMessagesResult> {
|
||||
const { conversationId, courseId, userId, messages } = options;
|
||||
|
||||
if (!conversationId || messages.length <= MIN_MESSAGES_BEFORE_SUMMARY) {
|
||||
return { messages: trimMessageHistory(messages) as AgentMessage[], summarized: false };
|
||||
}
|
||||
|
||||
const existingContext = await getChatModelContext(conversationId, userId);
|
||||
const splitIndex = Math.max(0, messages.length - KEEP_RECENT_MESSAGES);
|
||||
const messagesToRepresent = messages.slice(0, splitIndex);
|
||||
const recentMessages = messages.slice(splitIndex);
|
||||
const compactedThroughMessageId = getMessageId(messagesToRepresent[messagesToRepresent.length - 1]);
|
||||
|
||||
if (messagesToRepresent.length === 0) {
|
||||
return { messages: trimMessageHistory(messages) as AgentMessage[], summarized: false };
|
||||
}
|
||||
|
||||
let modelSummary = existingContext?.modelSummary ?? '';
|
||||
let summarized = false;
|
||||
|
||||
if (compactedThroughMessageId && existingContext?.compactedThroughMessageId !== compactedThroughMessageId) {
|
||||
const previousCompactedIndex = existingContext?.compactedThroughMessageId
|
||||
? messagesToRepresent.findIndex((message) => getMessageId(message) === existingContext.compactedThroughMessageId)
|
||||
: -1;
|
||||
const deltaStartIndex = previousCompactedIndex >= 0 ? previousCompactedIndex + 1 : 0;
|
||||
const deltaMessages = messagesToRepresent.slice(deltaStartIndex);
|
||||
|
||||
if (deltaMessages.length > 0) {
|
||||
modelSummary = await summarizeConversationDelta({
|
||||
existingSummary: modelSummary,
|
||||
messages: deltaMessages
|
||||
});
|
||||
summarized = true;
|
||||
}
|
||||
|
||||
await upsertChatModelContext({
|
||||
conversationId,
|
||||
courseId,
|
||||
userId,
|
||||
modelSummary,
|
||||
compactedThroughMessageId,
|
||||
sourceIds: existingContext?.sourceIds ?? []
|
||||
});
|
||||
}
|
||||
|
||||
if (!modelSummary.trim()) {
|
||||
return { messages: trimMessageHistory(messages) as AgentMessage[], summarized };
|
||||
}
|
||||
|
||||
const contextManagedMessages = [buildSummarySeed(modelSummary, compactedThroughMessageId), ...recentMessages];
|
||||
|
||||
return {
|
||||
messages: trimMessageHistory(contextManagedMessages) as AgentMessage[],
|
||||
summarized,
|
||||
compactedThroughMessageId
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
appendAgentRunInstruction,
|
||||
createAgentRun,
|
||||
createAgentRunEvent,
|
||||
getAgentRun,
|
||||
getChatConversation,
|
||||
listAgentRunEvents,
|
||||
listAgentRuns,
|
||||
listAgentRunSteps,
|
||||
updateAgentRun,
|
||||
type AgentRunInstruction
|
||||
} from '@cio/db/queries/agent';
|
||||
import { isCourseTeamMemberOrOrgAdmin } from '@cio/db/queries/group';
|
||||
import { getCourseOrganizationId } from '@cio/db/queries/tag';
|
||||
import type { TAiAgentRun, TAiAgentRunEvent, TAiAgentRunStatus, TAiAgentRunStep } from '@cio/db/types';
|
||||
import { AppError } from '@api/utils/errors';
|
||||
|
||||
const ACTIVE_RUN_STATUSES = new Set<TAiAgentRunStatus>(['queued', 'running', 'waiting_for_input', 'paused']);
|
||||
|
||||
export interface CreateAgentRunStateInput {
|
||||
orgId: string;
|
||||
userId: string;
|
||||
courseId: string;
|
||||
conversationId?: string;
|
||||
phase?: string;
|
||||
approvedPlan?: Record<string, unknown>;
|
||||
executionCursor?: Record<string, unknown>;
|
||||
sourceIds?: string[];
|
||||
modelSummary?: string;
|
||||
}
|
||||
|
||||
export interface AgentRunDetail {
|
||||
run: TAiAgentRun;
|
||||
steps: TAiAgentRunStep[];
|
||||
events: TAiAgentRunEvent[];
|
||||
}
|
||||
|
||||
async function assertCourseRunAccess(courseId: string, userId: string, orgId: string): Promise<void> {
|
||||
const courseOrgId = await getCourseOrganizationId(courseId);
|
||||
|
||||
if (!courseOrgId) {
|
||||
throw new AppError('Course not found', 'COURSE_NOT_FOUND', 404);
|
||||
}
|
||||
|
||||
if (courseOrgId !== orgId) {
|
||||
throw new AppError('Course does not belong to this organization', 'COURSE_ORG_MISMATCH', 403);
|
||||
}
|
||||
|
||||
const hasCourseAccess = await isCourseTeamMemberOrOrgAdmin(courseId, userId);
|
||||
|
||||
if (!hasCourseAccess) {
|
||||
throw new AppError('You must be a course team member to manage agent runs', 'NOT_COURSE_TEAM_MEMBER', 403);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertConversationBelongsToCourse(
|
||||
conversationId: string | undefined,
|
||||
courseId: string,
|
||||
userId: string
|
||||
): Promise<void> {
|
||||
if (!conversationId) return;
|
||||
|
||||
const conversation = await getChatConversation(conversationId, userId);
|
||||
|
||||
if (!conversation || conversation.courseId !== courseId) {
|
||||
throw new AppError('Conversation not found for this course', 'CONVERSATION_NOT_FOUND', 404);
|
||||
}
|
||||
}
|
||||
|
||||
async function getOwnedAgentRun(runId: string, userId: string, orgId: string): Promise<TAiAgentRun> {
|
||||
const run = await getAgentRun(runId, userId);
|
||||
|
||||
if (!run) {
|
||||
throw new AppError('Agent run not found', 'AGENT_RUN_NOT_FOUND', 404);
|
||||
}
|
||||
|
||||
if (run.orgId !== orgId) {
|
||||
throw new AppError('Agent run does not belong to this organization', 'AGENT_RUN_ORG_MISMATCH', 403);
|
||||
}
|
||||
|
||||
return run;
|
||||
}
|
||||
|
||||
async function buildAgentRunDetail(run: TAiAgentRun): Promise<AgentRunDetail> {
|
||||
const [steps, events] = await Promise.all([listAgentRunSteps(run.id), listAgentRunEvents(run.id)]);
|
||||
|
||||
return { run, steps, events };
|
||||
}
|
||||
|
||||
async function updateOwnedAgentRun(
|
||||
runId: string,
|
||||
userId: string,
|
||||
update: Omit<Parameters<typeof updateAgentRun>[0], 'runId' | 'userId'>
|
||||
): Promise<TAiAgentRun> {
|
||||
const updatedRun = await updateAgentRun({ runId, userId, ...update });
|
||||
|
||||
if (!updatedRun) {
|
||||
throw new AppError('Agent run not found', 'AGENT_RUN_NOT_FOUND', 404);
|
||||
}
|
||||
|
||||
return updatedRun;
|
||||
}
|
||||
|
||||
export async function createAgentRunState(input: CreateAgentRunStateInput): Promise<AgentRunDetail> {
|
||||
await assertCourseRunAccess(input.courseId, input.userId, input.orgId);
|
||||
await assertConversationBelongsToCourse(input.conversationId, input.courseId, input.userId);
|
||||
|
||||
const run = await createAgentRun({
|
||||
orgId: input.orgId,
|
||||
courseId: input.courseId,
|
||||
conversationId: input.conversationId,
|
||||
userId: input.userId,
|
||||
phase: input.phase,
|
||||
approvedPlan: input.approvedPlan ?? null,
|
||||
executionCursor: input.executionCursor,
|
||||
sourceIds: input.sourceIds,
|
||||
modelSummary: input.modelSummary
|
||||
});
|
||||
|
||||
await createAgentRunEvent({
|
||||
runId: run.id,
|
||||
eventType: 'run.created',
|
||||
message: 'Agent run created',
|
||||
payload: { phase: run.phase, status: run.status }
|
||||
});
|
||||
|
||||
return buildAgentRunDetail(run);
|
||||
}
|
||||
|
||||
export async function listAgentRunStates(courseId: string, userId: string, orgId: string): Promise<TAiAgentRun[]> {
|
||||
await assertCourseRunAccess(courseId, userId, orgId);
|
||||
|
||||
return listAgentRuns(courseId, userId);
|
||||
}
|
||||
|
||||
export async function getAgentRunState(runId: string, userId: string, orgId: string): Promise<AgentRunDetail> {
|
||||
const run = await getOwnedAgentRun(runId, userId, orgId);
|
||||
|
||||
return buildAgentRunDetail(run);
|
||||
}
|
||||
|
||||
export async function cancelAgentRunState(runId: string, userId: string, orgId: string): Promise<AgentRunDetail> {
|
||||
const run = await getOwnedAgentRun(runId, userId, orgId);
|
||||
|
||||
if (run.status === 'canceled') {
|
||||
return buildAgentRunDetail(run);
|
||||
}
|
||||
|
||||
if (run.status === 'completed') {
|
||||
throw new AppError('Completed agent runs cannot be canceled', 'AGENT_RUN_ALREADY_COMPLETED', 409);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const updatedRun = await updateOwnedAgentRun(runId, userId, {
|
||||
status: 'canceled',
|
||||
phase: 'canceled',
|
||||
cancelRequestedAt: now,
|
||||
finishedAt: now,
|
||||
workerId: null,
|
||||
lockedAt: null
|
||||
});
|
||||
|
||||
await createAgentRunEvent({
|
||||
runId,
|
||||
eventType: 'run.canceled',
|
||||
message: 'Cancel requested',
|
||||
payload: { previousStatus: run.status, previousPhase: run.phase }
|
||||
});
|
||||
|
||||
return buildAgentRunDetail(updatedRun);
|
||||
}
|
||||
|
||||
export async function resumeAgentRunState(runId: string, userId: string, orgId: string): Promise<AgentRunDetail> {
|
||||
const run = await getOwnedAgentRun(runId, userId, orgId);
|
||||
|
||||
if (run.status === 'queued' || run.status === 'running') {
|
||||
return buildAgentRunDetail(run);
|
||||
}
|
||||
|
||||
if (run.status === 'completed') {
|
||||
throw new AppError('Completed agent runs cannot be resumed', 'AGENT_RUN_ALREADY_COMPLETED', 409);
|
||||
}
|
||||
|
||||
if (run.status === 'canceled') {
|
||||
throw new AppError('Canceled agent runs cannot be resumed', 'AGENT_RUN_CANCELED', 409);
|
||||
}
|
||||
|
||||
if (run.status === 'failed') {
|
||||
throw new AppError('Failed agent runs must be retried', 'AGENT_RUN_RETRY_REQUIRED', 409);
|
||||
}
|
||||
|
||||
const updatedRun = await updateOwnedAgentRun(runId, userId, {
|
||||
status: 'queued',
|
||||
cancelRequestedAt: null,
|
||||
finishedAt: null,
|
||||
workerId: null,
|
||||
lockedAt: null
|
||||
});
|
||||
|
||||
await createAgentRunEvent({
|
||||
runId,
|
||||
eventType: 'run.resumed',
|
||||
message: 'Run resumed',
|
||||
payload: { previousStatus: run.status, phase: run.phase }
|
||||
});
|
||||
|
||||
return buildAgentRunDetail(updatedRun);
|
||||
}
|
||||
|
||||
export async function retryAgentRunState(runId: string, userId: string, orgId: string): Promise<AgentRunDetail> {
|
||||
const run = await getOwnedAgentRun(runId, userId, orgId);
|
||||
|
||||
if (run.status !== 'failed') {
|
||||
throw new AppError('Only failed agent runs can be retried', 'AGENT_RUN_NOT_RETRYABLE', 409);
|
||||
}
|
||||
|
||||
if (run.attempt >= run.maxAttempts) {
|
||||
throw new AppError('Agent run has reached its retry limit', 'AGENT_RUN_RETRY_LIMIT_REACHED', 409);
|
||||
}
|
||||
|
||||
const updatedRun = await updateOwnedAgentRun(runId, userId, {
|
||||
status: 'queued',
|
||||
attempt: run.attempt + 1,
|
||||
lastError: null,
|
||||
cancelRequestedAt: null,
|
||||
finishedAt: null,
|
||||
workerId: null,
|
||||
lockedAt: null
|
||||
});
|
||||
|
||||
await createAgentRunEvent({
|
||||
runId,
|
||||
eventType: 'run.retried',
|
||||
message: 'Run queued for retry',
|
||||
payload: { previousAttempt: run.attempt, nextAttempt: updatedRun.attempt }
|
||||
});
|
||||
|
||||
return buildAgentRunDetail(updatedRun);
|
||||
}
|
||||
|
||||
export async function appendAgentRunInstructionState(
|
||||
runId: string,
|
||||
userId: string,
|
||||
orgId: string,
|
||||
text: string
|
||||
): Promise<AgentRunDetail> {
|
||||
const run = await getOwnedAgentRun(runId, userId, orgId);
|
||||
|
||||
if (!ACTIVE_RUN_STATUSES.has(run.status) && run.status !== 'failed') {
|
||||
throw new AppError('Agent run is no longer accepting instructions', 'AGENT_RUN_NOT_ACCEPTING_INPUT', 409);
|
||||
}
|
||||
|
||||
const instruction: AgentRunInstruction = {
|
||||
id: randomUUID(),
|
||||
text,
|
||||
createdBy: userId,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
const updatedRun = await appendAgentRunInstruction(runId, userId, instruction);
|
||||
|
||||
if (!updatedRun) {
|
||||
throw new AppError('Agent run not found', 'AGENT_RUN_NOT_FOUND', 404);
|
||||
}
|
||||
|
||||
await createAgentRunEvent({
|
||||
runId,
|
||||
eventType: 'instruction.queued',
|
||||
message: 'Instruction queued',
|
||||
payload: { instructionId: instruction.id }
|
||||
});
|
||||
|
||||
return buildAgentRunDetail(updatedRun);
|
||||
}
|
||||
@@ -38,6 +38,10 @@ export interface SummarizeOptions {
|
||||
messages: InboundMessage[];
|
||||
}
|
||||
|
||||
export interface SummarizeDeltaOptions extends SummarizeOptions {
|
||||
existingSummary?: string | null;
|
||||
}
|
||||
|
||||
function truncate(input: string, max: number): string {
|
||||
if (input.length <= max) return input;
|
||||
|
||||
@@ -138,3 +142,45 @@ export async function summarizeConversation(options: SummarizeOptions): Promise<
|
||||
|
||||
return result.text.trim();
|
||||
}
|
||||
|
||||
export async function summarizeConversationDelta(options: SummarizeDeltaOptions): Promise<string> {
|
||||
if (!options.existingSummary?.trim()) {
|
||||
return summarizeConversation({ messages: options.messages });
|
||||
}
|
||||
|
||||
const providerConfig = pickAnyConfiguredProvider();
|
||||
|
||||
if (!providerConfig) {
|
||||
throw new AppError('AI assistant is not configured', 'AI_NOT_CONFIGURED', 503);
|
||||
}
|
||||
|
||||
const model = createModel(providerConfig);
|
||||
const conversationLines: string[] = [];
|
||||
|
||||
for (const message of options.messages ?? []) {
|
||||
const role = message.role === 'user' ? 'User' : 'Assistant';
|
||||
const body = partsToText(message);
|
||||
|
||||
if (!body.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
conversationLines.push(`${role}: ${body}`);
|
||||
}
|
||||
|
||||
const conversationText = conversationLines.join('\n\n');
|
||||
const truncatedText =
|
||||
conversationText.length > MAX_CONVERSATION_CHARS
|
||||
? conversationText.slice(conversationText.length - MAX_CONVERSATION_CHARS)
|
||||
: conversationText;
|
||||
|
||||
const result = await generateText({
|
||||
model,
|
||||
system: SUMMARIZE_SYSTEM_PROMPT,
|
||||
prompt: `Existing structured handoff summary:\n\n${options.existingSummary.trim()}\n\nNew conversation messages to merge into that summary:\n\n${truncatedText}\n\nReturn one updated structured handoff message using the required section headings. Preserve concrete plan status, template answers, fetched documentation URLs/source identifiers, created course items, open decisions, and the next action.`,
|
||||
maxOutputTokens: MAX_OUTPUT_TOKENS,
|
||||
maxRetries: 0
|
||||
});
|
||||
|
||||
return result.text.trim();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user