208 lines
6.4 KiB
TypeScript
208 lines
6.4 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import cors from "cors";
|
|
import express from "express";
|
|
|
|
import { SessionTranscriptStore } from "./sessions/transcriptStore.js";
|
|
import { ChatSessionBridge } from "./chat/sessionBridge.js";
|
|
import { config } from "./config.js";
|
|
import { SessionUiStateStore } from "./sessions/uiStateStore.js";
|
|
import { SessionMetadataStore } from "./sessions/metadataStore.js";
|
|
import { logger } from "./logger.js";
|
|
import { LearningOrchestrator } from "./learning/orchestrator.js";
|
|
import { MemoryStore } from "./memory/store.js";
|
|
import { ResultReferenceResolver } from "./results/resolver.js";
|
|
import { ResultReferenceStore } from "./results/store.js";
|
|
import { buildChatRouter } from "./routes/chat.js";
|
|
import { FrontendActionCoordinator, FrontendActionError } from "./frontendAction/coordinator.js";
|
|
import { opencodeRuntime } from "./runtime/opencode.js";
|
|
import { getRuntimeSessionContext } from "./runtime/sessionContext.js";
|
|
|
|
const app = express();
|
|
|
|
// 这里集中组装 Agent 服务的运行期依赖,路由层只通过接口调用,便于测试时替换实现。
|
|
const sessionBridge = new ChatSessionBridge(opencodeRuntime);
|
|
const sessionMetadataStore = new SessionMetadataStore();
|
|
const sessionUiStateStore = new SessionUiStateStore();
|
|
const memoryStore = new MemoryStore();
|
|
const sessionTranscriptStore = new SessionTranscriptStore();
|
|
const learningOrchestrator = new LearningOrchestrator(
|
|
opencodeRuntime,
|
|
memoryStore,
|
|
sessionTranscriptStore,
|
|
);
|
|
const resultReferenceStore = new ResultReferenceStore();
|
|
const resultReferenceResolver = new ResultReferenceResolver(resultReferenceStore);
|
|
const internalToken = config.AGENT_INTERNAL_TOKEN ?? randomUUID();
|
|
const frontendActionCoordinator = new FrontendActionCoordinator();
|
|
|
|
process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken;
|
|
|
|
app.use(cors());
|
|
app.use(express.json({ limit: "1mb" }));
|
|
|
|
app.post("/internal/frontend-actions/request", async (req, res) => {
|
|
if (req.header("x-agent-internal-token") !== internalToken) {
|
|
res.status(403).json({ message: "forbidden" });
|
|
return;
|
|
}
|
|
const runtimeSessionId = typeof req.body?.session_id === "string" ? req.body.session_id.trim() : "";
|
|
const context = runtimeSessionId ? getRuntimeSessionContext(runtimeSessionId) : null;
|
|
if (!context) {
|
|
res.status(404).json({ message: "session context not found" });
|
|
return;
|
|
}
|
|
if (!context.capabilities?.includes("frontend-action@1")) {
|
|
// Legacy clients still execute the existing UIEnvelope emitted from the tool call.
|
|
res.json({
|
|
version: "frontend-action-result@1",
|
|
status: "succeeded",
|
|
output: { legacy: true },
|
|
detail: "legacy client executes the action from its UIEnvelope",
|
|
});
|
|
return;
|
|
}
|
|
try {
|
|
const result = await frontendActionCoordinator.request({
|
|
sessionId: context.clientSessionId,
|
|
toolCallId: typeof req.body?.call_id === "string" ? req.body.call_id : "",
|
|
name: typeof req.body?.name === "string" ? req.body.name : "",
|
|
params: req.body?.params,
|
|
fallbackText: typeof req.body?.fallback_text === "string" ? req.body.fallback_text : undefined,
|
|
});
|
|
res.json(result);
|
|
} catch (error) {
|
|
const status = error instanceof FrontendActionError ? error.status : 500;
|
|
res.status(status).json({ message: error instanceof Error ? error.message : String(error) });
|
|
}
|
|
});
|
|
|
|
app.get("/health", async (_req, res) => {
|
|
try {
|
|
const runtime = await opencodeRuntime.health();
|
|
res.json({
|
|
ok: true,
|
|
runtime,
|
|
sessions: sessionBridge.count(),
|
|
});
|
|
} catch (error) {
|
|
const detail = error instanceof Error ? error.message : String(error);
|
|
res.status(503).json({
|
|
ok: false,
|
|
message: "opencode runtime unavailable",
|
|
detail,
|
|
sessions: sessionBridge.count(),
|
|
});
|
|
}
|
|
});
|
|
|
|
app.post("/internal/tools/session-search", async (req, res) => {
|
|
if (req.header("x-agent-internal-token") !== internalToken) {
|
|
res.status(403).json({ message: "forbidden" });
|
|
return;
|
|
}
|
|
|
|
const sessionId =
|
|
typeof req.body?.session_id === "string" ? req.body.session_id.trim() : "";
|
|
const query = typeof req.body?.query === "string" ? req.body.query : "";
|
|
const context = sessionId ? getRuntimeSessionContext(sessionId) : null;
|
|
if (!context) {
|
|
res.status(404).json({
|
|
message: "session context not found",
|
|
detail: sessionId,
|
|
});
|
|
return;
|
|
}
|
|
if (!query.trim()) {
|
|
res.status(400).json({ message: "query is required" });
|
|
return;
|
|
}
|
|
const hits = await sessionTranscriptStore.search(
|
|
{
|
|
actorKey: context.actorKey,
|
|
projectKey: context.projectKey,
|
|
},
|
|
query,
|
|
typeof req.body?.max_results === "number" ? req.body.max_results : undefined,
|
|
);
|
|
res.json({
|
|
hits,
|
|
query,
|
|
});
|
|
});
|
|
|
|
app.use(
|
|
"/api/v1/agent/chat",
|
|
buildChatRouter(
|
|
sessionBridge,
|
|
opencodeRuntime,
|
|
sessionMetadataStore,
|
|
sessionUiStateStore,
|
|
memoryStore,
|
|
sessionTranscriptStore,
|
|
learningOrchestrator,
|
|
resultReferenceResolver,
|
|
frontendActionCoordinator,
|
|
),
|
|
);
|
|
|
|
const bootstrap = async () => {
|
|
await Promise.all([
|
|
sessionMetadataStore.initialize(),
|
|
sessionUiStateStore.initialize(),
|
|
learningOrchestrator.initialize(),
|
|
memoryStore.initialize(),
|
|
resultReferenceStore.initialize(),
|
|
sessionTranscriptStore.initialize(),
|
|
]);
|
|
resultReferenceStore.startCleanupLoop();
|
|
};
|
|
|
|
await bootstrap();
|
|
|
|
const server = app.listen(config.PORT, config.HOST, () => {
|
|
logger.info(
|
|
{ host: config.HOST, port: config.PORT },
|
|
"TJWaterAgent listening",
|
|
);
|
|
void warmupOpencodeRuntime();
|
|
});
|
|
|
|
const warmupOpencodeRuntime = async () => {
|
|
const startedAt = Date.now();
|
|
try {
|
|
await opencodeRuntime.ensureClient();
|
|
logger.info(
|
|
{
|
|
elapsedMs: Math.max(0, Date.now() - startedAt),
|
|
mode: config.OPENCODE_MODE,
|
|
},
|
|
"opencode runtime warmed up",
|
|
);
|
|
} catch (error) {
|
|
logger.error(
|
|
{
|
|
err: error,
|
|
elapsedMs: Math.max(0, Date.now() - startedAt),
|
|
mode: config.OPENCODE_MODE,
|
|
},
|
|
"failed to warm up opencode runtime",
|
|
);
|
|
}
|
|
};
|
|
|
|
const shutdown = async () => {
|
|
logger.info("shutting down TJWaterAgent");
|
|
server.close();
|
|
resultReferenceStore.stopCleanupLoop();
|
|
// 同步关闭 embedded opencode server,避免本服务退出后留下孤儿进程。
|
|
await opencodeRuntime.dispose();
|
|
};
|
|
|
|
process.on("SIGINT", () => {
|
|
void shutdown();
|
|
});
|
|
|
|
process.on("SIGTERM", () => {
|
|
void shutdown();
|
|
});
|