gadget/gadget-code/src/services/chat-session.ts
Rob Colbert d04453016d Phase 1: SubProcess service and agent tool
Complete Phase 1 of subprocess management in gadget-drone:
- SubProcessService with graceful shutdown (SIGINT→SIGKILL), halt, getLog
- SubprocessTool with create/list/kill/killAll/halt/log commands
- Registered in agent toolbox for build/test/ship/develop modes
- Fixed missing {{process_management_block}} replacement in chat-session.ts
- 34 unit tests across service and tool
- Comprehensive documentation in docs/subprocess.md
2026-05-14 12:59:05 -04:00

487 lines
12 KiB
TypeScript

// src/services/chat-session.ts
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
// All Rights Reserved
import env from "../config/env.ts";
import path from "node:path";
import fs from "node:fs";
import dayjs from "dayjs";
import {
IChatSession,
ChatSessionMode,
GadgetId,
IUser,
IProject,
ChatTurnStatus,
ChatTurnDocument,
ReasoningEffort,
} from "@gadget/api";
import { IAiProvider } from "@gadget/api";
import Project from "../models/project.ts";
import ChatTurn from "../models/chat-turn.ts";
import ChatSession from "../models/chat-session.ts";
import AiProvider from "../models/ai-provider.ts";
import { DtpService } from "../lib/service.ts";
import { PopulateOptions } from "mongoose";
import {
AiApi,
createAiApi,
IAiEnvironment,
IAiProvider as IAiApiProvider,
} from "@gadget/ai";
const aiEnv: IAiEnvironment = {
NODE_ENV: env.NODE_ENV || "develop",
services: {
google: {
cse: {
apiKey: env.google.cse.apiKey,
engineId: env.google.cse.engineId,
},
},
},
};
class ChatSessionService extends DtpService {
public populateChatSession: PopulateOptions[] = [
{ path: "user", select: "-passwordSalt -password" },
{
path: "project",
populate: [
{
path: "user",
select: "-passwordSalt -password",
},
],
},
{ path: "provider", select: "+apiKey" },
];
public populateChatTurn: PopulateOptions[] = [
{
path: "user",
select: "-passwordSalt -password",
},
{
path: "project",
},
{
path: "session",
populate: this.populateChatSession,
},
{
path: "provider",
select: "-models",
},
];
get name(): string {
return "ChatSessionService";
}
get slug(): string {
return "svc:chat-session";
}
async start(): Promise<void> {
this.log.info("started");
}
async stop(): Promise<void> {
this.log.info("stopped");
}
/**
* Creates a new chat session.
*/
async create(
userId: GadgetId,
projectId: GadgetId,
providerId: GadgetId,
selectedModel: string,
mode: ChatSessionMode = ChatSessionMode.Build,
name?: string,
): Promise<IChatSession> {
// Validate project exists
const project = await Project.findById(projectId);
if (!project) {
throw new Error(`Project not found: ${projectId}`);
}
// Validate provider exists
const provider = await AiProvider.findById(providerId);
if (!provider) {
throw new Error(`AI Provider not found: ${providerId}`);
}
const session = new ChatSession({
createdAt: new Date(),
user: userId,
project: projectId,
name: name || "New Chat Session",
mode,
provider: providerId,
selectedModel,
stats: {
turnCount: 0,
toolCallCount: 0,
inputTokens: 0,
outputTokens: 0,
},
pins: [],
});
await session.save();
this.log.info("chat session created", {
sessionId: session._id,
projectId,
providerId,
model: selectedModel,
});
return session.populate(this.populateChatSession);
}
/**
* Gets a chat session by ID.
*/
async getById(chatSessionId: GadgetId): Promise<IChatSession> {
const session = await ChatSession.findById(chatSessionId)
.populate("user", "-passwordSalt -password")
.populate("project")
.populate("provider", "+apiKey")
.lean();
if (!session) {
throw new Error(`Chat session not found: ${chatSessionId}`);
}
return session as unknown as IChatSession;
}
/**
* Gets all chat sessions for a project.
*/
async getByProject(projectId: GadgetId): Promise<IChatSession[]> {
const sessions = await ChatSession.find({ project: projectId })
.populate("user", "-passwordSalt -password")
.populate("project")
.populate("provider", "+apiKey")
.sort({ createdAt: -1 })
.lean();
return sessions as unknown as IChatSession[];
}
/**
* Gets all chat sessions for a user.
*/
async getByUser(userId: GadgetId): Promise<IChatSession[]> {
const sessions = await ChatSession.find({ user: userId })
.populate("user", "-passwordSalt -password")
.populate("project")
.populate("provider", "+apiKey")
.sort({ createdAt: -1 })
.lean();
return sessions as unknown as IChatSession[];
}
/**
* Updates a chat session.
*/
async update(
chatSessionId: GadgetId,
updates: Partial<{
name: string;
provider: GadgetId;
selectedModel: string;
mode: ChatSessionMode;
reasoningEffort: ReasoningEffort;
}>,
): Promise<IChatSession> {
const session = await ChatSession.findById(chatSessionId);
if (!session) {
throw new Error(`Chat session not found: ${chatSessionId}`);
}
// Validate provider if changing
if (updates.provider) {
const provider = await AiProvider.findById(updates.provider);
if (!provider) {
throw new Error(`AI Provider not found: ${updates.provider}`);
}
session.provider = updates.provider;
}
if (updates.name !== undefined) {
session.name = updates.name;
}
if (updates.selectedModel !== undefined) {
session.selectedModel = updates.selectedModel;
}
if (updates.mode !== undefined) {
session.mode = updates.mode;
}
if (updates.reasoningEffort !== undefined) {
session.reasoningEffort = updates.reasoningEffort;
}
await session.save();
this.log.info("chat session updated", {
sessionId: chatSessionId,
updates,
});
return session.populate(this.populateChatSession);
}
/**
* Deletes a chat session.
*/
async delete(chatSessionId: GadgetId): Promise<void> {
const session = await ChatSession.findByIdAndDelete(chatSessionId);
if (!session) {
throw new Error(`Chat session not found: ${chatSessionId}`);
}
// Delete all turns associated with this session
await ChatTurn.deleteMany({ session: chatSessionId });
this.log.info("chat session deleted", {
sessionId: chatSessionId,
});
}
async createTurn(
session: IChatSession,
prompt: string,
): Promise<ChatTurnDocument> {
const NOW = new Date();
const user: IUser = session.user as IUser;
const project: IProject = session.project as IProject;
const systemPrompt = await this.buildSystemPrompt(session);
let turn = new ChatTurn({
createdAt: NOW,
user: user._id,
project: project._id,
session: session._id,
provider: session.provider,
llm: session.selectedModel,
reasoningEffort: (session as IChatSession).reasoningEffort || "off",
mode: session.mode,
status: ChatTurnStatus.Processing,
prompts: {
system: systemPrompt,
user: prompt,
},
toolCalls: [],
subagents: [],
stats: {
toolCallCount: 0,
inputTokens: 0,
thinkingTokenCount: 0,
responseTokens: 0,
durationMs: 0,
durationLabel: "pending",
},
});
await turn.save();
turn = await ChatTurn.populate(turn, this.populateChatTurn);
return turn;
}
async buildSystemPrompt(session: IChatSession): Promise<string> {
const TIMESTAMP_FORMAT = "YYYY-MM-DD hh:mm a";
const user = session.user as IUser;
const provider = session.provider as IAiProvider;
const project = session.project as IProject;
const commonDir = path.join(env.installDir, "data", "prompts", "common");
const promptsDir = path.join(
env.installDir,
"data",
"prompts",
"agent",
session.mode,
);
const common = {
processManagementBlock: await fs.promises.readFile(
path.join(commonDir, "process-management-block.md"),
"utf-8",
),
scopeBlock: await fs.promises.readFile(
path.join(commonDir, "scope-block.md"),
"utf-8",
),
subagentsBlock: await fs.promises.readFile(
path.join(commonDir, "subagents.md"),
"utf-8",
),
toolsBlock: await fs.promises.readFile(
path.join(commonDir, "tools-block.md"),
"utf-8",
),
};
/*
* User Persona
*/
const personaBlock = [
`USER ID: ${user._id}`,
`NAME: ${user.displayName}`,
`IS ADMIN: ${user.flags.isAdmin ? "YES" : "NO"}`,
`---[BEGIN PERSONA]---`,
user.persona ||
"I haven't set a persona. Encourage me to visit Settings and describe myself!",
`---[END PERSONA]---`,
].join("\n");
/*
* Session Information
*/
const sessionBlock = [
`Session ID: ${session._id}`,
`Session Name: ${session.name}`,
`Session Created At: ${dayjs(session.createdAt).format(TIMESTAMP_FORMAT)}`,
`AI Provider: ${provider.name}`,
`AI API: ${provider.apiType}`,
`Large Language Model: ${session.selectedModel}`,
`Project Name: ${project.name}`,
`Project Created At: ${dayjs(project.createdAt).format(TIMESTAMP_FORMAT)}`,
].join("\n");
const templateFilename = path.join(promptsDir, "system.md");
const promptTemplate = await fs.promises.readFile(
templateFilename,
"utf-8",
);
let prompt = promptTemplate
.replace("{{process_management_block}}", common.processManagementBlock)
.replace("{{scope_block}}", common.scopeBlock)
.replace("{{tools}}", common.toolsBlock)
.replace("{{subagent_section}}", common.subagentsBlock)
.replace("{{session_block}}", sessionBlock)
.replace("{{persona_block}}", personaBlock);
return prompt;
}
/**
* Gets all turns for a chat session.
*/
async getTurns(chatSessionId: GadgetId): Promise<any[]> {
const turns = await ChatTurn.find({
session: chatSessionId,
status: { $ne: ChatTurnStatus.Processing },
})
.populate("user", "-passwordSalt -password")
.populate("project")
.populate("provider", "-models")
.sort({ createdAt: 1 })
.lean();
return turns;
}
/**
* Generates a session name from the first prompt.
*/
async generateSessionNameFromPrompt(
session: IChatSession,
prompt: string,
): Promise<IChatSession> {
const dbProvider: IAiProvider = session.provider as IAiProvider;
const provider: IAiApiProvider = this.mapDbProviderToConfig(dbProvider);
if (!provider.apiKey) {
const error = new Error(
`Provider "${provider.name}" (${provider._id}) has no apiKey configured`,
);
error.statusCode = 400;
throw error;
}
this.log.info("calling provider to generate chat session title", {
provider: {
_id: provider._id,
name: provider.name,
},
selectedModel: session.selectedModel,
});
const api: AiApi = createAiApi(aiEnv, provider, this.log);
const systemPrompt = [
"ROLE:",
"You are an assistant that creates titles for chat sessions by examining the first prompt submitted by the user.",
"",
"SCOPE:",
"You return just the title of the session, no explanation or justifications. You return one title, not multiple choices.",
"Select the best title for the chat session, and return that.",
"",
"CONSTRAINTS:",
"- Chat session titles shouldn't be longer than 60 characters.",
"- Chat session titles shouldn't contain vulgarity or degeneracy.",
].join("\n");
const response = await api.generate(
{
provider,
modelId: session.selectedModel,
params: {
reasoning: false,
temperature: 1.0,
topK: 0.6,
topP: 0.4,
numPredict: -1,
numCtx: 131072,
maxCompletionTokens: 256,
},
},
{
systemPrompt,
prompt: `Here is the first prompt submitted by the user:\n\n${prompt}`,
},
);
const newSession = await ChatSession.findOneAndUpdate(
{ _id: session._id },
{ $set: { name: response.response || "New Session" } },
{ new: true, populate: this.populateChatSession },
);
if (!newSession) {
const error = new Error("chat session has been removed");
error.statusCode = 404;
throw error;
}
return newSession;
}
mapDbProviderToConfig(provider: IAiProvider | GadgetId): IAiApiProvider {
if (typeof provider === "string") {
throw new Error("Provider must be populated, not a GadgetId reference");
}
return {
_id: provider._id,
name: provider.name,
sdk: provider.apiType, // map apiType → sdk
baseUrl: provider.baseUrl,
apiKey: provider.apiKey,
};
}
}
export default new ChatSessionService();