From 3a53542d1080ea0d90a45ce829a11a0d9d986490 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:54:37 +0800 Subject: [PATCH] fix(service-ai): stamp agent_id on auto-created chat conversations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/api/v1/ai/agents/:agentName/chat` auto-created its conversation via autoCreateConversation() with only userId + metadata, leaving ai_conversations.agent_id NULL. That makes per-agent attribution (analytics, and cloud's per-agent AI metering) impossible — every conversation looked agent-less. Thread the agent through: add ToolExecutionContext.agentId (spec), set it to the path agentName in agent-routes, and forward ctx.agentId into conversationService.create({ agentId }) in autoCreateConversation. Additive and backward-compatible (undefined → null, unchanged for the general /ai/chat route and system invocations). Test: ai-service auto-creates the conversation with the context agentId. Co-Authored-By: Claude Opus 4.8 --- .../src/__tests__/ai-service.test.ts | 21 +++++++++++++++++++ .../services/service-ai/src/ai-service.ts | 5 ++++- .../service-ai/src/routes/agent-routes.ts | 3 +++ packages/spec/src/contracts/ai-service.ts | 7 +++++++ 4 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/services/service-ai/src/__tests__/ai-service.test.ts b/packages/services/service-ai/src/__tests__/ai-service.test.ts index 9b7e23d9c0..e914714888 100644 --- a/packages/services/service-ai/src/__tests__/ai-service.test.ts +++ b/packages/services/service-ai/src/__tests__/ai-service.test.ts @@ -394,6 +394,27 @@ describe('AIService', () => { expect(after?.messages[1].content).toBe('hello back'); }); + it('auto-creates the conversation with the agentId from the execution context', async () => { + const conversationService = new InMemoryConversationService(); + const adapter: LLMAdapter = { + name: 'agentid-test', + chat: async () => ({ content: 'ok', model: 'test' }), + complete: async () => ({ content: '' }), + }; + const service = new AIService({ adapter, conversationService, logger: silentLogger }); + + // No conversationId → autoCreateConversation fires (needs an actor); it must + // stamp agent_id so downstream per-agent attribution/metering isn't null. + await service.chatWithTools( + [{ role: 'user', content: 'hi' }], + { toolExecutionContext: { actor: { id: 'u1' }, agentId: 'ask' } }, + ); + + const mine = await conversationService.list({ agentId: 'ask' }); + expect(mine.length).toBe(1); + expect(mine[0].agentId).toBe('ask'); + }); + it('chatWithTools persists assistant + tool turns across iterations', async () => { const conversationService = new InMemoryConversationService(); const toolRegistry = new ToolRegistry(); diff --git a/packages/services/service-ai/src/ai-service.ts b/packages/services/service-ai/src/ai-service.ts index 7cbdf9b702..db7ddc62b3 100644 --- a/packages/services/service-ai/src/ai-service.ts +++ b/packages/services/service-ai/src/ai-service.ts @@ -322,13 +322,16 @@ export class AIService implements IAIService { * which case we silently fall back to non-persisted chat). */ private async autoCreateConversation( - ctx: { actor?: { id?: string }; environmentId?: string } | undefined, + ctx: { actor?: { id?: string }; environmentId?: string; agentId?: string } | undefined, ): Promise { const actorId = ctx?.actor?.id; if (!actorId) return undefined; try { const conv = await this.conversationService.create({ userId: actorId, + // Stamp the agent so the conversation (and its messages' usage) is + // attributable per-agent instead of null (e.g. cloud per-agent metering). + agentId: ctx?.agentId, metadata: ctx?.environmentId ? { environmentId: ctx.environmentId } : undefined, }); this.logger.debug('[AI] auto-created conversation', { conversationId: conv.id, actorId }); diff --git a/packages/services/service-ai/src/routes/agent-routes.ts b/packages/services/service-ai/src/routes/agent-routes.ts index 2468d0f6e0..1ba01092dd 100644 --- a/packages/services/service-ai/src/routes/agent-routes.ts +++ b/packages/services/service-ai/src/routes/agent-routes.ts @@ -256,6 +256,9 @@ export function buildAgentRoutes( typeof chatContext?.environmentId === 'string' ? chatContext.environmentId : undefined, + // The agent this chat runs as → stamped onto an auto-created + // conversation's agent_id (per-agent attribution / metering). + agentId: agentName, // The object/view the user has open — lets built-in data // tools fall back to "this object" when the request doesn't // name one (ADR-aligned with the schema injection above). diff --git a/packages/spec/src/contracts/ai-service.ts b/packages/spec/src/contracts/ai-service.ts index 81c48b8075..6b202e2f01 100644 --- a/packages/spec/src/contracts/ai-service.ts +++ b/packages/spec/src/contracts/ai-service.ts @@ -410,6 +410,13 @@ export interface ToolExecutionContext { messageId?: string; /** Active environment (multi-tenant project) id, if known. */ environmentId?: string; + /** + * The agent this chat runs as (e.g. the `:agentName` from + * `/api/v1/ai/agents/:agentName/chat`). Stamped onto an auto-created + * conversation's `agent_id` so downstream analytics / per-agent metering can + * attribute usage to the right agent instead of leaving it null. + */ + agentId?: string; /** * Object the user is currently viewing in the UI (e.g. the list/detail * page they have open). Built-in data tools use this as a fallback target