feat(agent): unify MCP tools and adopt Responses delivery

Move project and host queries into native MCP with host-owned OAuth/context,
while retaining memory, skills, actions and publication plugins. Add nearby-model
queries and explicit chat/task/learning model defaults.

Adopt Responses HTTP/SSE and native steer/queue inbox delivery, preserving scoped
session ownership, pending input edits and investigation communication.

Disable local-provider discovery: Server /health allowed native vLLM to probe
/v1/models on port 8000. A native before/after regression prevents recurrence.

Validation: 170 focused Bun tests; service/plugin typechecks; native discovery,
provider-discovery and input-inbox smoke checks; no paid model inference.
This commit is contained in:
2026-10-19 18:46:58 +08:00
parent f5c3cde2c2
commit 6a49d2209e
164 changed files with 12286 additions and 2368 deletions
+108 -24
View File
@@ -1,12 +1,12 @@
import { join } from "node:path";
import { config } from "../config.js";
import { type SessionWorkspaceStore } from "./workspace-store.js";
import {
atomicWriteJson,
ensureDirectory,
listJsonFiles,
readJsonFile,
removeFileIfExists,
slugify,
} from "../utils/fileStore.js";
@@ -23,6 +23,7 @@ export type SessionRecord = {
createdAt: string;
updatedAt: string;
status: SessionStatus;
deletedAt?: string;
purpose?: SessionPurpose;
taskId?: string;
runId?: string;
@@ -65,13 +66,33 @@ type EnsureSessionMetadataInput = SessionMetadataContext & {
};
export class SessionMetadataStore {
constructor(private readonly baseDir = config.SESSION_METADATA_STORAGE_DIR) {}
private readonly ensureQueues = new Map<string, Promise<void>>();
private index?: Map<string, Map<string, SessionRecord>>;
private indexLoading?: Promise<void>;
private indexVersion = 0;
constructor(private readonly baseDir = config.SESSION_METADATA_STORAGE_DIR, private readonly workspaces?: SessionWorkspaceStore) {
workspaces?.setDeletionCheck(async (context) => Boolean((await this.get(context, context.sessionId, { includeDeleted: true }))?.deletedAt));
}
async initialize() {
await ensureDirectory(this.baseDir);
}
async ensure(input: EnsureSessionMetadataInput) {
return this.serialize(input.sessionId, () => this.ensureRecord(input));
}
private async serialize<T>(sessionId: string, operation: () => Promise<T>): Promise<T> {
const key = this.filePath(sessionId);
const previous = this.ensureQueues.get(key) ?? Promise.resolve();
const result = previous.catch(() => undefined).then(operation);
const pending = result.then(() => undefined, () => undefined);
this.ensureQueues.set(key, pending);
try { return await result; }
finally { if (this.ensureQueues.get(key) === pending) this.ensureQueues.delete(key); }
}
private async ensureRecord(input: EnsureSessionMetadataInput) {
const sessionId = normalizeSessionId(input.sessionId);
if (!sessionId) {
throw new Error("sessionId is required");
@@ -81,7 +102,7 @@ export class SessionMetadataStore {
);
if (existing) {
if (
!matchesContext(existing, input) ||
existing.deletedAt || existing.sessionId !== sessionId || !matchesContext(existing, input) ||
(input.trigger && existing.triggerId !== input.trigger.triggerId) ||
(input.purpose && readPurpose(existing) !== input.purpose) ||
(input.taskId && existing.taskId !== input.taskId.trim()) ||
@@ -91,10 +112,12 @@ export class SessionMetadataStore {
) {
throw new SessionContextMismatchError();
}
if (this.workspaces) await this.workspaces.ensure({ actorKey: existing.actorKey, projectKey: existing.projectKey, sessionId });
return { created: false, record: existing };
}
const now = new Date().toISOString();
if (this.workspaces) await this.workspaces.ensure({ actorKey: input.actorKey, projectKey: input.projectKey, sessionId });
const record: SessionRecord = {
sessionId,
actorKey: input.actorKey,
@@ -122,11 +145,13 @@ export class SessionMetadataStore {
await atomicWriteJson(
this.filePath(record.sessionId),
record,
0o600,
);
this.updateIndex(record);
return { created: true, record };
}
async get(context: SessionMetadataContext, sessionId: string) {
async get(context: SessionMetadataContext, sessionId: string, options?: { includeDeleted?: boolean }) {
const normalizedSessionId = normalizeSessionId(sessionId);
if (!normalizedSessionId) {
return null;
@@ -134,39 +159,45 @@ export class SessionMetadataStore {
const record = await readJsonFile<SessionRecord>(
this.filePath(normalizedSessionId),
);
return record && matchesContext(record, context) ? record : null;
return record && (!record.deletedAt || options?.includeDeleted) && record.sessionId === normalizedSessionId && matchesContext(record, context) ? record : null;
}
// Internal delivery only, after Server has authorized and resolved this run.
// Does not authorize ordinary chat access or expose a global session lookup.
async getTaskExecutionBinding(projectId: string, taskId: string, runId: string, sessionId: string) {
const id = normalizeSessionId(sessionId);
if (!id) return null;
const record = await readJsonFile<SessionRecord>(this.filePath(id));
return record && !record.deletedAt && record.sessionId === id
&& record.projectId === projectId && record.taskId === taskId && record.runId === runId
&& readPurpose(record) === "task_execution" ? record : null;
}
async touch(
record: SessionRecord,
updates: Partial<Pick<SessionRecord, "title" | "status">> = {},
) {
const next: SessionRecord = {
...record,
...normalizeSessionUpdates(updates),
updatedAt: new Date().toISOString(),
};
await atomicWriteJson(
this.filePath(record.sessionId),
next,
);
return next;
return this.serialize(record.sessionId, async () => {
const current = await this.get({ ...record, userId: record.ownerUserId }, record.sessionId);
if (!current) throw new SessionContextMismatchError();
const next: SessionRecord = { ...current, ...normalizeSessionUpdates(updates), updatedAt: new Date().toISOString() };
await atomicWriteJson(this.filePath(record.sessionId), next, 0o600);
this.updateIndex(next);
return next;
});
}
async list(
context: SessionMetadataContext,
filter?: { purpose?: SessionPurpose; taskId?: string },
) {
const files = await listJsonFiles(this.baseDir);
const records = await Promise.all(
files.map((file) => readJsonFile<SessionRecord>(file)),
);
await this.loadIndex();
const records = [...(this.index?.get(this.scopeKey(context))?.values() ?? [])].map((record) => ({ ...record }));
return records
.filter((record): record is SessionRecord => Boolean(record))
.filter(
(record) =>
record.actorKey === context.actorKey &&
record.projectKey === context.projectKey,
!record.deletedAt && matchesContext(record, context),
)
.filter((record) => !filter?.purpose || readPurpose(record) === filter.purpose)
.filter((record) => !filter?.taskId || record.taskId === filter.taskId.trim())
@@ -199,9 +230,62 @@ export class SessionMetadataStore {
}
async remove(record: SessionRecord) {
await removeFileIfExists(
this.filePath(record.sessionId),
);
return this.serialize(record.sessionId, async () => {
const current = await this.get({ ...record, userId: record.ownerUserId }, record.sessionId, { includeDeleted: true });
if (!current) throw new SessionContextMismatchError();
// Retain only identity and deletion state, so legacy imports and late writes cannot revive it.
const deleted: SessionRecord = {
sessionId: current.sessionId, actorKey: current.actorKey, projectKey: current.projectKey,
ownerUserId: current.ownerUserId, projectId: current.projectId,
createdAt: current.createdAt, updatedAt: new Date().toISOString(), status: "archived",
deletedAt: current.deletedAt ?? new Date().toISOString(),
};
await atomicWriteJson(this.filePath(record.sessionId), deleted, 0o600);
this.updateIndex(deleted);
await this.workspaces?.markDeleted(deleted);
});
}
async purge(record: SessionRecord) {
const current = await this.get({ ...record, userId: record.ownerUserId }, record.sessionId, { includeDeleted: true });
if (!current?.deletedAt) throw new SessionContextMismatchError();
await this.workspaces?.purge(current);
}
private scopeKey(context: { actorKey: string; projectKey: string }) {
return JSON.stringify([context.actorKey, context.projectKey]);
}
private updateIndex(record: SessionRecord) {
this.indexVersion++;
if (!this.index) return;
const key = this.scopeKey(record);
const records = this.index.get(key) ?? new Map<string, SessionRecord>();
records.set(record.sessionId, { ...record });
this.index.set(key, records);
}
private async loadIndex(): Promise<void> {
if (this.index) return;
if (this.indexLoading) { await this.indexLoading; return this.loadIndex(); }
const version = this.indexVersion;
const loading = (async () => {
const files = await listJsonFiles(this.baseDir);
const records = await Promise.all(files.map((file) => readJsonFile<SessionRecord>(file)));
const index = new Map<string, Map<string, SessionRecord>>();
for (const record of records) {
if (!record) continue;
const key = this.scopeKey(record);
const scoped = index.get(key) ?? new Map<string, SessionRecord>();
scoped.set(record.sessionId, record);
index.set(key, scoped);
}
if (version === this.indexVersion) this.index = index;
})();
this.indexLoading = loading;
try { await loading; }
finally { if (this.indexLoading === loading) this.indexLoading = undefined; }
if (!this.index) await this.loadIndex();
}
private filePath(sessionId: string) {