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
487 lines
12 KiB
TypeScript
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();
|