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
4 changes: 4 additions & 0 deletions shared/DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions shared/agent/agent.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -69,7 +69,7 @@ export type AgentRunningState =
| 'hitl_feedback'
| 'hitl_user'
| 'completed'
| 'shutdown'
| 'restart'
| 'child_agents'
| 'timeout';

Expand Down
2 changes: 1 addition & 1 deletion shared/agent/agent.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
],
Expand Down
21 changes: 9 additions & 12 deletions shared/llm/llm.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,18 +129,15 @@ export const ToolContentSchema = Type.Array(ToolResultSchema, { $id: 'ToolConten
// type ToolLlmContent = Extract<LlmMessage, { role: 'tool' }>['content'];
const _ToolContentCheck: AreTypesFullyCompatible<ToolContent, Static<typeof ToolContentSchema>> = 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<FinishReason, Static<typeof FinishReasonSchema>> = true;

export const GenerationStatsSchema = Type.Object({
Expand Down
6 changes: 3 additions & 3 deletions src/agent/agentContextService/agentContextService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
});

Expand Down Expand Up @@ -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']);
});
});
Expand All @@ -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

Expand Down
10 changes: 9 additions & 1 deletion src/functions/scm/gitlab.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -464,6 +464,14 @@ export class GitLab extends AbstractSCM implements SourceControlManagement {
return logs;
}

async findDiscussionIdByNoteId(projectId: string | number, mergeRequestIid: number, noteId: number): Promise<DiscussionSchema | null> {
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.
Expand Down
4 changes: 2 additions & 2 deletions src/llm/services/ai-llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,8 +278,8 @@ export abstract class AiLLM<Provider extends ProviderV2> 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<any, any> = await generateText(args);

Expand Down
2 changes: 1 addition & 1 deletion src/modules/firestore/firestoreAgentStateService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ export class FirestoreAgentStateService implements AgentContextService {
async listRunning(): Promise<AgentContextPreview[]> {
// 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
Expand Down
2 changes: 1 addition & 1 deletion src/modules/memory/inMemoryAgentStateService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export class InMemoryAgentStateService implements AgentContextService {

async listRunning(): Promise<AgentContextPreview[]> {
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));
}

Expand Down
2 changes: 1 addition & 1 deletion src/modules/mongo/MongoAgentContextService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ export class MongoAgentContextService implements AgentContextService {
async listRunning(): Promise<AgentContextPreview[]> {
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<string, 0 | 1> = { _id: 0 };
(AGENT_PREVIEW_KEYS as unknown as Array<keyof AgentContext | '_id'>).forEach((key) => {
Expand Down
2 changes: 1 addition & 1 deletion src/modules/postgres/postgresAgentStateService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,7 @@ export class PostgresAgentStateService implements AgentContextService {

async listRunning(): Promise<AgentContextPreview[]> {
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
Expand Down
84 changes: 59 additions & 25 deletions src/routes/webhooks/gitlab/gitlabNoteHandler.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<string | null> {
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 {
Expand Down Expand Up @@ -65,24 +59,53 @@ export type MergeRequestNoteEvent = WebhookMergeRequestNoteEventSchema & { merge

export async function handleMergeRequestNoteEvent(event: MergeRequestNoteEvent): Promise<AgentExecution | null> {
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 ?? '<unknown-user>';
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 = '<discussion>';
for (const note of discussion.notes ?? []) {
if (note.body.includes(AGENT_TAG)) {
agentTaggedInDiscussion = true;
}
discussionText += `\n<discussion:comment authorUsername="${note.author.username}" authorName="${note.author.name}">\n${note.body}\n</discussion:comment>`;
}
discussionText += '\n</discussion>';
}
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];
Expand All @@ -97,31 +120,42 @@ 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,
};
if (jiraDetails) {
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',
Expand All @@ -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,
},
Expand Down
2 changes: 1 addition & 1 deletion src/routes/webhooks/gitlab/gitlabPipelineHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down