From 139c05d8e7b4488c3aaecc565bc4e32ab8e950f7 Mon Sep 17 00:00:00 2001 From: Daniel Campagnoli Date: Fri, 10 Oct 2025 13:59:29 +0800 Subject: [PATCH 1/2] Update gitlab note handler. Change shutdown state to restart. --- shared/DOCS.md | 4 + shared/agent/agent.model.ts | 4 +- shared/agent/agent.schema.ts | 2 +- shared/llm/llm.schema.ts | 21 ++--- .../agentContextService.test.ts | 6 +- src/functions/scm/gitlab.ts | 10 ++- src/llm/services/ai-llm.ts | 4 +- .../firestore/firestoreAgentStateService.ts | 2 +- .../memory/inMemoryAgentStateService.ts | 2 +- src/modules/mongo/MongoAgentContextService.ts | 2 +- .../postgres/postgresAgentStateService.ts | 2 +- .../webhooks/gitlab/gitlabNoteHandler.ts | 84 +++++++++++++------ 12 files changed, 93 insertions(+), 50 deletions(-) diff --git a/shared/DOCS.md b/shared/DOCS.md index 522311c6d..2d6706560 100644 --- a/shared/DOCS.md +++ b/shared/DOCS.md @@ -68,6 +68,10 @@ If there is a compile failure on this line then there is a mismatch between the The compile error `error TS2322: Type 'true' is not assignable to type 'false'` indicates a type mismatch. +### Schema Ids + +Schema Ids only need to be provided to top level objects. When a schema is used as a property of another schema, the id is not needed, and can cause errors. +e.g. "FastifyError: Failed building the serialization schema for GET: /api/agent/details, due to error reference "SubEntity" resolves to more than one schema" ## APIs diff --git a/shared/agent/agent.model.ts b/shared/agent/agent.model.ts index d2203baf6..02f3caaf5 100644 --- a/shared/agent/agent.model.ts +++ b/shared/agent/agent.model.ts @@ -55,7 +55,7 @@ export interface AgentCompleted { * feedback - deprecated version of hitl_feedback * child_agents - stopped waiting for child agents to complete * completed - the agent has called the completed function. - * shutdown - if the agent has stopped after being instructed by the system to pause (e.g. for server shutdown) + * restart - Before the server is restarted (for upgrade etc), running agents are stopped and set to 'restart' to indicate they should be restarted on the server restart. * timeout - for chat agents when there hasn't been a user input for a configured amount of time */ export type AgentRunningState = @@ -69,7 +69,7 @@ export type AgentRunningState = | 'hitl_feedback' | 'hitl_user' | 'completed' - | 'shutdown' + | 'restart' | 'child_agents' | 'timeout'; diff --git a/shared/agent/agent.schema.ts b/shared/agent/agent.schema.ts index a4e0a6822..3f664d47f 100644 --- a/shared/agent/agent.schema.ts +++ b/shared/agent/agent.schema.ts @@ -25,7 +25,7 @@ export const AgentRunningStateSchema = Type.Union( Type.Literal('hitl_feedback'), Type.Literal('hitl_user'), Type.Literal('completed'), - Type.Literal('shutdown'), + Type.Literal('restart'), Type.Literal('child_agents'), Type.Literal('timeout'), ], diff --git a/shared/llm/llm.schema.ts b/shared/llm/llm.schema.ts index e5d230466..bfe591615 100644 --- a/shared/llm/llm.schema.ts +++ b/shared/llm/llm.schema.ts @@ -129,18 +129,15 @@ export const ToolContentSchema = Type.Array(ToolResultSchema, { $id: 'ToolConten // type ToolLlmContent = Extract['content']; const _ToolContentCheck: AreTypesFullyCompatible> = true; -export const FinishReasonSchema = Type.Union( - [ - Type.Literal('stop'), - Type.Literal('length'), - Type.Literal('content-filter'), - Type.Literal('tool-calls'), - Type.Literal('error'), - Type.Literal('other'), - Type.Literal('unknown'), - ], - { $id: 'FinishReason' }, -); +export const FinishReasonSchema = Type.Union([ + Type.Literal('stop'), + Type.Literal('length'), + Type.Literal('content-filter'), + Type.Literal('tool-calls'), + Type.Literal('error'), + Type.Literal('other'), + Type.Literal('unknown'), +]); const _FinishReasonCheck: AreTypesFullyCompatible> = true; export const GenerationStatsSchema = Type.Object({ diff --git a/src/agent/agentContextService/agentContextService.test.ts b/src/agent/agentContextService/agentContextService.test.ts index 6b9619597..e3207d125 100644 --- a/src/agent/agentContextService/agentContextService.test.ts +++ b/src/agent/agentContextService/agentContextService.test.ts @@ -486,7 +486,7 @@ export function runAgentStateServiceTests( // Terminal states (should NOT be listed) await service.save(createMockAgentContext(agentId(), { state: 'completed', lastUpdate: Date.now() - 600 })); - await service.save(createMockAgentContext(agentId(), { state: 'shutdown', lastUpdate: Date.now() - 700 })); + await service.save(createMockAgentContext(agentId(), { state: 'restart', lastUpdate: Date.now() - 700 })); await service.save(createMockAgentContext(agentId(), { state: 'timeout', lastUpdate: Date.now() - 800 })); }); @@ -518,7 +518,7 @@ export function runAgentStateServiceTests( // Verify that no terminal states (including 'error' as per service impl) are included contexts.forEach((ctx) => { - expect(ctx.state).to.not.be.oneOf(['completed', 'shutdown', 'timeout', 'error']); + expect(ctx.state).to.not.be.oneOf(['completed', 'restart', 'timeout', 'error']); expect(ctx).to.include.keys(['agentId', 'state', 'lastUpdate']); }); }); @@ -528,7 +528,7 @@ export function runAgentStateServiceTests( service = createService(); // Recreate service after clearing // Save only agents with terminal states await service.save(createMockAgentContext(agentId(), { state: 'completed' })); - await service.save(createMockAgentContext(agentId(), { state: 'shutdown' })); + await service.save(createMockAgentContext(agentId(), { state: 'restart' })); await service.save(createMockAgentContext(agentId(), { state: 'timeout' })); await service.save(createMockAgentContext(agentId(), { state: 'error' })); // Also save an error state one diff --git a/src/functions/scm/gitlab.ts b/src/functions/scm/gitlab.ts index b34b12e8c..2e3af47c1 100644 --- a/src/functions/scm/gitlab.ts +++ b/src/functions/scm/gitlab.ts @@ -1,4 +1,4 @@ -import type { EventSchema, Gitlab, JobSchema, SimpleProjectSchema, SimpleUserSchema } from '@gitbeaker/core'; +import type { DiscussionSchema, EventSchema, Gitlab, JobSchema, SimpleProjectSchema, SimpleUserSchema } from '@gitbeaker/core'; import { type CommitDiffSchema, type CreateMergeRequestOptions, @@ -464,6 +464,14 @@ export class GitLab extends AbstractSCM implements SourceControlManagement { return logs; } + async findDiscussionIdByNoteId(projectId: string | number, mergeRequestIid: number, noteId: number): Promise { + const discussions = await this.api().MergeRequestDiscussions.all(projectId, mergeRequestIid); + for (const d of discussions) { + if (d.notes?.some((n: any) => n.id === noteId)) return d; + } + return null; + } + /** * Gets the list of branches for a given GitLab project. * @param projectId The full project path (e.g., 'group/subgroup/project') or the numeric project ID. diff --git a/src/llm/services/ai-llm.ts b/src/llm/services/ai-llm.ts index 20d252fdc..c2997dd2b 100644 --- a/src/llm/services/ai-llm.ts +++ b/src/llm/services/ai-llm.ts @@ -278,8 +278,8 @@ export abstract class AiLLM extends BaseLLM { providerOptions, // abortSignal: combinedOpts.abortSignal, }; - // Messages can be large so just log the reference to the LlmCall its saved in - logger.info({ args: { ...args, messages: `LlmCall:${llmCall.id}` } }, `Generating text - ${opts?.id}`); + // Messages can be large, and model property with schemas, so just log the reference to the LlmCall its saved in + logger.info({ args: { ...args, messages: `LlmCall:${llmCall.id}`, model: this.getId() } }, `Generating text - ${opts?.id}`); const result: GenerateTextResult = await generateText(args); diff --git a/src/modules/firestore/firestoreAgentStateService.ts b/src/modules/firestore/firestoreAgentStateService.ts index bee4998bc..e83f2d2cd 100644 --- a/src/modules/firestore/firestoreAgentStateService.ts +++ b/src/modules/firestore/firestoreAgentStateService.ts @@ -200,7 +200,7 @@ export class FirestoreAgentStateService implements AgentContextService { async listRunning(): Promise { // Define terminal states to exclude from the "running" list // TODO this list should be defined in agent.model.ts - const terminalStates: AgentRunningState[] = ['completed', 'shutdown', 'timeout', 'error']; // Added 'error' as it's typically terminal + const terminalStates: AgentRunningState[] = ['completed', 'restart', 'timeout', 'error']; // Added 'error' as it's typically terminal // NOTE: This query requires a composite index in Firestore. // Example gcloud command: // gcloud firestore indexes composite create --collection-group=AgentContext --query-scope=COLLECTION --field-config field-path=user,order=ASCENDING --field-config field-path=state,operator=NOT_IN --field-config field-path=lastUpdate,order=DESCENDING diff --git a/src/modules/memory/inMemoryAgentStateService.ts b/src/modules/memory/inMemoryAgentStateService.ts index e7ac52d11..545d947a4 100644 --- a/src/modules/memory/inMemoryAgentStateService.ts +++ b/src/modules/memory/inMemoryAgentStateService.ts @@ -73,7 +73,7 @@ export class InMemoryAgentStateService implements AgentContextService { async listRunning(): Promise { const allAgentPreviews = await this.list(); // This will now return AgentContextPreview[] - const terminalStates: AgentRunningState[] = ['completed', 'shutdown', 'timeout', 'error']; + const terminalStates: AgentRunningState[] = ['completed', 'restart', 'timeout', 'error']; return allAgentPreviews.filter((preview) => !terminalStates.includes(preview.state)); } diff --git a/src/modules/mongo/MongoAgentContextService.ts b/src/modules/mongo/MongoAgentContextService.ts index 78cf430d1..fad15f99f 100644 --- a/src/modules/mongo/MongoAgentContextService.ts +++ b/src/modules/mongo/MongoAgentContextService.ts @@ -239,7 +239,7 @@ export class MongoAgentContextService implements AgentContextService { async listRunning(): Promise { try { const currentUserId = userContext.currentUser().id; // Get current user ID - const terminalStates: AgentRunningState[] = ['completed', 'error', 'shutdown', 'timeout']; + const terminalStates: AgentRunningState[] = ['completed', 'error', 'restart', 'timeout']; const projection: Record = { _id: 0 }; (AGENT_PREVIEW_KEYS as unknown as Array).forEach((key) => { diff --git a/src/modules/postgres/postgresAgentStateService.ts b/src/modules/postgres/postgresAgentStateService.ts index 03d3f755c..69bb113fe 100644 --- a/src/modules/postgres/postgresAgentStateService.ts +++ b/src/modules/postgres/postgresAgentStateService.ts @@ -402,7 +402,7 @@ export class PostgresAgentStateService implements AgentContextService { async listRunning(): Promise { const userId = currentUser().id; - const terminalStates: AgentRunningState[] = ['completed', 'shutdown', 'timeout', 'error']; + const terminalStates: AgentRunningState[] = ['completed', 'restart', 'timeout', 'error']; const rows = await this.db .selectFrom('agent_contexts') // Select only necessary columns for list view diff --git a/src/routes/webhooks/gitlab/gitlabNoteHandler.ts b/src/routes/webhooks/gitlab/gitlabNoteHandler.ts index 45b7f5fb8..1ee1741c9 100644 --- a/src/routes/webhooks/gitlab/gitlabNoteHandler.ts +++ b/src/routes/webhooks/gitlab/gitlabNoteHandler.ts @@ -1,7 +1,8 @@ -import { WebhookBaseNoteEventSchema, WebhookMergeRequestNoteEventSchema } from '@gitbeaker/core'; +import { DiscussionSchema, Gitlab, WebhookBaseNoteEventSchema, WebhookMergeRequestNoteEventSchema } from '@gitbeaker/core'; import { AgentExecution } from '#agent/agentExecutions'; import { getLastFunctionCallArg } from '#agent/autonomous/agentCompletion'; import { startAgent } from '#agent/autonomous/autonomousAgentRunner'; +import { AGENT_COMPLETED_NAME } from '#agent/autonomous/functions/agentFunctions'; import { FileSystemTree } from '#agent/autonomous/functions/fileSystemTree'; import { LiveFiles } from '#agent/autonomous/functions/liveFiles'; import { Jira } from '#functions/jira'; @@ -14,15 +15,8 @@ import { defaultLLMs } from '#llm/services/defaultLlms'; import { logger } from '#o11y/logger'; import { AgentCompleted, AgentContext } from '#shared/agent/agent.model'; -const AGENT_TAG = '@typedai'; - -async function findDiscussionIdByNoteId(projectId: string | number, mrIid: number, noteId: number): Promise { - const discussions = await new GitLab().api().MergeRequestDiscussions.all(projectId, mrIid); - for (const d of discussions) { - if (d.notes?.some((n: any) => n.id === noteId)) return d.id as string; - } - return null; -} +const AGENT_USERNAME = 'typedai'; +const AGENT_TAG = `@${AGENT_USERNAME}`; export class GitLabNoteCompletedHandler implements AgentCompleted { agentCompletedHandlerId(): string { @@ -65,24 +59,53 @@ export type MergeRequestNoteEvent = WebhookMergeRequestNoteEventSchema & { merge export async function handleMergeRequestNoteEvent(event: MergeRequestNoteEvent): Promise { const note = event.object_attributes; - const noteText = note.note ?? note.description; - // Only action if the AI agent has been tagged - if (!noteText.includes(AGENT_TAG)) return null; + const noteText = note.note ?? note.description; + const gitlab = new GitLab(); const project = event.project; const user = event.user; const userName = user.name ?? user.username ?? ''; const mergeRequest = event.merge_request; const discussionId: string | undefined = - event.object_attributes.discussion_id ?? (await findDiscussionIdByNoteId(project.id, mergeRequest.iid, note.id)) ?? undefined; + event.object_attributes.discussion_id ?? (await gitlab.findDiscussionIdByNoteId(project.id, mergeRequest.iid, note.id))?.id ?? undefined; + let agentTaggedInDiscussion = false; + let agentTaggedInNoted = false; + + let discussionText = ''; + + const discussion: DiscussionSchema | null = await gitlab.findDiscussionIdByNoteId(project.id, mergeRequest.iid, note.id); + if (discussion) { + // Don't process notes that the agent posted + if (discussion.notes?.at(-1)?.author.username === AGENT_USERNAME) { + logger.info(`Not processing note ${note.id} as it was posted by the agent`); + return null; + } + discussionText = ''; + for (const note of discussion.notes ?? []) { + if (note.body.includes(AGENT_TAG)) { + agentTaggedInDiscussion = true; + } + discussionText += `\n\n${note.body}\n`; + } + discussionText += '\n'; + } + if (noteText?.includes(AGENT_TAG)) { + agentTaggedInNoted = true; + } + + // Only action if the AI agent has been tagged in the discussion or the note + if (!agentTaggedInDiscussion && !agentTaggedInNoted) { + logger.info(`Not processing note ${note.id} as the agent was not tagged in the discussion or note`); + return null; + } const mergeRequestDetails = `Project: ${project.path_with_namespace}\nTitle: ${mergeRequest.title}\nSource branch: ${mergeRequest.source_branch}\nTarget branch: ${mergeRequest.target_branch}`; const supportFuncs = new SupportKnowledgebase(); const coreDocs = await supportFuncs.getCoreDocumentation().catch(() => ''); - const diffs = await new GitLab().getMergeRequestDiffs(project.id, mergeRequest.iid); + const diffs = await gitlab.getMergeRequestDiffs(project.id, mergeRequest.iid); // See if the source branch has a Jira Id in the start of the name, or start after a slash (e.g ABC-123-branch-descrption, feature/PROJ-5594-new-feature) const jiraId = mergeRequest.source_branch.match(/^(\w+-\d+)(?:\/|$)/)?.[1]; @@ -97,24 +120,33 @@ export async function handleMergeRequestNoteEvent(event: MergeRequestNoteEvent): const systemPrompt = 'You are an AI support agent. You are responding to message your are tagged in on a GitLab Merge Request. ' + - 'Respond in a helpful, concise manner. If you encounter an error responding to the request, do not provide details; ' + - 'respond with: "Sorry, I\'m having difficulties providing a response to your request".'; + 'Respond in a helpful, concise manner. If you encounter an persistant error calling tools to complete your response to the request, do not provide details; ' + + 'respond with what useful information you have discovered so far along with a comment like: "Sorry, I\'m encountering errors trying to provide a complete response to your request". ' + + 'Only assist with requests directly relevant to the Merge Request, and decline to assist with requests that are not relevant to the Merge Request.'; - const initialPrompt = [ + const initialPromptLines = [ `You are an AI support agent (${AGENT_TAG} is your username tag) responding to a GitLab Merge Request comment.`, `Comment by user: ${userName}${user?.username ? ` (@${user.username})` : ''}`, `GitLab Project: ${project.path_with_namespace}`, `MR IID: ${mergeRequest.iid}`, '', - 'Request:', - noteText, + 'Discussion:', + discussionText || noteText, '', - 'Use the available tools (GitLab, Jira, web search) as needed to answer accurately and concisely. Respond professionally and to the point', - ].join('\n'); + 'Use the available tools (GitLab, Jira, web search) as needed to answer accurately and concisely. Do not make any changes to the repository, only respond professionally and to the point', + `The value passed to the ${AGENT_COMPLETED_NAME} function will be the comment posted to the discussion.`, + ]; + if (!agentTaggedInNoted) { + initialPromptLines.push( + 'You have previously been tagged or commented in the discussion, however you are not tagged in this latest comment. If you think that the user does not need a response from you then call the completed function with an empty string.', + ); + } + const initialPrompt = initialPromptLines.join('\n'); const initialMemory = { 'support-knowledgebase-core-documentation': coreDocs, - 'mr-discussion-id': discussionId!, + 'merge-request-iid': mergeRequest.iid?.toString() ?? '', + 'merge-request-discussion-id': discussionId?.toString() ?? '', 'merge-request-details': mergeRequestDetails, 'merge-request-diff': diffs, }; @@ -122,6 +154,8 @@ export async function handleMergeRequestNoteEvent(event: MergeRequestNoteEvent): initialMemory['jira-details'] = jiraDetails; } + logger.info({ initialMemory, initialPrompt, mrIId: mergeRequest.iid, project: project.path_with_namespace }, 'Starting agent for MR note'); + try { const exec = await startAgent({ type: 'autonomous', @@ -135,9 +169,9 @@ export async function handleMergeRequestNoteEvent(event: MergeRequestNoteEvent): gitlab: { projectId: project.id, projectPath: project.path_with_namespace, - mergeRequestIid: mergeRequest.iid, + mergeRequestIid: mergeRequest.iid?.toString() ?? '', branch: mergeRequest.source_branch, - discussionId, + discussionId: discussion?.id?.toString() ?? '', noteId: note.id, webUrl: mergeRequest.url, }, From 8e6b17cd8b7bc617f9643a06f80059dd038f3d02 Mon Sep 17 00:00:00 2001 From: Daniel Campagnoli Date: Fri, 10 Oct 2025 14:48:31 +0800 Subject: [PATCH 2/2] Update gitlabPipelineHandler.ts --- src/routes/webhooks/gitlab/gitlabPipelineHandler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/webhooks/gitlab/gitlabPipelineHandler.ts b/src/routes/webhooks/gitlab/gitlabPipelineHandler.ts index fe88d4aff..12da34c81 100644 --- a/src/routes/webhooks/gitlab/gitlabPipelineHandler.ts +++ b/src/routes/webhooks/gitlab/gitlabPipelineHandler.ts @@ -19,7 +19,7 @@ export async function handlePipelineEvent(event: WebhookPipelineEventSchema) { if (pipeline.status === 'success') { // check if there is a CodeTask and notify it of a successful build - } else { + } else if (pipeline.status !== 'running') { const mergeRequest = event.merge_request; // may be null if (mergeRequest) { failedLogs = await new GitLab().getMergeRequestPipelineFailedJobLogs(event.project.id, mergeRequest.iid);