Improve AI agent chat, tool display, and workflow agent management (#17876)

## Summary

- **Fix token renewal endpoint**: Use `/metadata` instead of `/graphql`
for token renewal in agent chat, fixing auth issues
- **Improve tool display**: Add `load_skills` support, show formatted
tool names (underscores → spaces) with finish/loading states, display
tool icons during loading, and support custom loading messages from tool
input
- **Refactor workflow agent management**: Replace direct
`AgentRepository` access with `AgentService` for create/delete/find
operations in workflow steps, improving encapsulation and consistency
- **Simplify Apollo client usage**: Remove explicit Apollo client
override in `useGetToolIndex`, add `AgentChatProvider` to
`AppRouterProviders`
- **Fix load-skill tool**: Change parameter type from `string` to `json`
for proper schema parsing
- **Update agent-chat-streaming**: Use `AgentService` for agent
resolution and tool registration instead of direct repository queries

## Test plan

- [ ] Verify AI agent chat works end-to-end (send message, receive
response)
- [ ] Verify tool steps display correctly with icons and proper messages
during loading and after completion
- [ ] Verify workflow AI agent step creation and deletion works
correctly
- [ ] Verify workflow version cloning preserves agent configuration
- [ ] Verify token renewal works when tokens expire during agent chat


Made with [Cursor](https://cursor.com)

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
Félix Malfait
2026-02-13 10:27:38 +01:00
committed by GitHub
parent 5c3c2e08a6
commit 21c51ec251
39 changed files with 815 additions and 656 deletions
@@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { createUIMessageStream, pipeUIMessageStreamToResponse } from 'ai';
@@ -34,8 +34,6 @@ export type StreamAgentChatOptions = {
@Injectable()
export class AgentChatStreamingService {
private readonly logger = new Logger(AgentChatStreamingService.name);
constructor(
@InjectRepository(AgentChatThreadEntity)
private readonly threadRepository: Repository<AgentChatThreadEntity>,
@@ -65,6 +63,25 @@ export class AgentChatStreamingService {
);
}
// Fire user-message save without awaiting to avoid delaying time-to-first-letter.
// The promise is awaited inside onFinish where we need the turnId.
const lastUserText =
messages[messages.length - 1]?.parts.find((part) => part.type === 'text')
?.text ?? '';
const userMessagePromise = this.agentChatService.addMessage({
threadId: thread.id,
uiMessage: {
role: AgentMessageRole.USER,
parts: [{ type: 'text', text: lastUserText }],
},
});
// Prevent unhandled rejection if onFinish never runs (e.g. stream
// setup error or empty response early-return). The real error still
// surfaces when awaited in onFinish.
userMessagePromise.catch(() => {});
try {
const uiStream = createUIMessageStream<ExtendedUIMessage>({
execute: async ({ writer }) => {
@@ -97,8 +114,6 @@ export class AgentChatStreamingService {
writer.merge(
stream.toUIMessageStream({
onError: (error) => {
this.logger.error('Stream error:', error);
return error instanceof Error ? error.message : String(error);
},
sendStart: false,
@@ -179,57 +194,26 @@ export class AgentChatStreamingService {
return;
}
const validThreadId = thread.id;
const userMessage = await userMessagePromise;
if (!validThreadId) {
this.logger.error('Thread ID is unexpectedly null/undefined');
await this.agentChatService.addMessage({
threadId: thread.id,
uiMessage: responseMessage,
turnId: userMessage.turnId,
});
return;
}
try {
const userMessage = await this.agentChatService.addMessage({
threadId: validThreadId,
uiMessage: {
role: AgentMessageRole.USER,
parts: [
{
type: 'text',
text:
messages[messages.length - 1].parts.find(
(part) => part.type === 'text',
)?.text ?? '',
},
],
},
});
await this.agentChatService.addMessage({
threadId: validThreadId,
uiMessage: responseMessage,
turnId: userMessage.turnId,
});
await this.threadRepository.update(validThreadId, {
totalInputTokens: () =>
`"totalInputTokens" + ${streamUsage.inputTokens}`,
totalOutputTokens: () =>
`"totalOutputTokens" + ${streamUsage.outputTokens}`,
totalInputCredits: () =>
`"totalInputCredits" + ${streamUsage.inputCredits}`,
totalOutputCredits: () =>
`"totalOutputCredits" + ${streamUsage.outputCredits}`,
contextWindowTokens: modelConfig.contextWindowTokens,
conversationSize: lastStepConversationSize,
});
} catch (saveError) {
this.logger.error(
'Failed to save messages:',
saveError instanceof Error
? saveError.message
: String(saveError),
);
}
await this.threadRepository.update(thread.id, {
totalInputTokens: () =>
`"totalInputTokens" + ${streamUsage.inputTokens}`,
totalOutputTokens: () =>
`"totalOutputTokens" + ${streamUsage.outputTokens}`,
totalInputCredits: () =>
`"totalInputCredits" + ${streamUsage.inputCredits}`,
totalOutputCredits: () =>
`"totalOutputCredits" + ${streamUsage.outputCredits}`,
contextWindowTokens: modelConfig.contextWindowTokens,
conversationSize: lastStepConversationSize,
});
},
sendReasoning: true,
}),
@@ -237,13 +221,18 @@ export class AgentChatStreamingService {
},
});
pipeUIMessageStreamToResponse({ stream: uiStream, response });
pipeUIMessageStreamToResponse({
stream: uiStream,
response,
// Consume the stream independently so onFinish fires even if
// the client disconnects (e.g., page refresh mid-stream)
consumeSseStream: ({ stream }) => {
stream.pipeTo(new WritableStream()).catch(() => {});
},
});
} catch (error) {
this.logger.error(
'Failed to stream chat:',
error instanceof Error ? error.message : String(error),
);
response.end();
throw error;
}
}
}
@@ -3,7 +3,7 @@ import { Injectable } from '@nestjs/common';
import { COMMON_PRELOAD_TOOLS } from 'src/engine/core-modules/tool-provider/constants/common-preload-tools.const';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import {
EXECUTE_TOOL_TOOL_NAME,
LEARN_TOOLS_TOOL_NAME,
@@ -131,7 +131,7 @@ export class SystemPromptBuilderService {
}
buildFullPrompt(
toolCatalog: ToolDescriptor[],
toolCatalog: ToolIndexEntry[],
skillCatalog: FlatSkill[],
preloadedTools: string[],
contextString?: string,
@@ -242,12 +242,12 @@ ${skillsList}`;
}
buildToolCatalogSection(
toolCatalog: ToolDescriptor[],
toolCatalog: ToolIndexEntry[],
preloadedTools: string[],
): string {
const preloadedSet = new Set(preloadedTools);
const toolsByCategory = new Map<string, ToolDescriptor[]>();
const toolsByCategory = new Map<string, ToolIndexEntry[]>();
for (const tool of toolCatalog) {
const category = tool.category;