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:
@@ -75,21 +75,35 @@ type TurnReviewInput = {
|
||||
userMessage: string;
|
||||
};
|
||||
|
||||
type LearningJob = { cancelled: boolean; sessions: Set<string>; promise?: Promise<void> };
|
||||
const learningContext = (input: TurnReviewInput) => ({
|
||||
actorKey: input.requestContext.actorKey, projectKey: input.requestContext.projectKey,
|
||||
sessionId: input.requestContext.clientSessionId,
|
||||
});
|
||||
|
||||
export class LearningOrchestrator {
|
||||
private readonly activeReviews = new Set<string>();
|
||||
private readonly sessionLearningStateStore = new SessionLearningStateStore();
|
||||
private readonly activeReviews = new Map<string, LearningJob>();
|
||||
private readonly skillStore = new SkillStore();
|
||||
|
||||
constructor(
|
||||
private readonly runtime: OpencodeRuntimeAdapter,
|
||||
private readonly memoryStore: MemoryStore,
|
||||
private readonly transcriptStore: SessionTranscriptStore,
|
||||
private readonly sessionLearningStateStore = new SessionLearningStateStore(),
|
||||
) {}
|
||||
|
||||
async initialize() {
|
||||
await this.sessionLearningStateStore.initialize();
|
||||
}
|
||||
|
||||
async cancelSession(sessionId: string) {
|
||||
const job = this.activeReviews.get(sessionId);
|
||||
if (!job) return;
|
||||
job.cancelled = true;
|
||||
await Promise.all([...job.sessions].map((id) => this.runtime.abortSession(id)));
|
||||
await job.promise;
|
||||
}
|
||||
|
||||
async onTurnCompleted(input: TurnReviewInput) {
|
||||
const transcript = await this.transcriptStore.appendTurn(
|
||||
{
|
||||
@@ -108,9 +122,10 @@ export class LearningOrchestrator {
|
||||
if (this.activeReviews.has(input.sessionId)) {
|
||||
return;
|
||||
}
|
||||
this.activeReviews.add(input.sessionId);
|
||||
const job: LearningJob = { cancelled: false, sessions: new Set() };
|
||||
this.activeReviews.set(input.sessionId, job);
|
||||
try {
|
||||
const state = await this.sessionLearningStateStore.read(input.sessionId);
|
||||
const state = await this.sessionLearningStateStore.read(learningContext(input));
|
||||
const turnsSinceGate = Math.max(0, turnCount - state.lastGatedTurn);
|
||||
if (turnsSinceGate < config.LEARNING_GATE_TURN_COOLDOWN) {
|
||||
this.activeReviews.delete(input.sessionId);
|
||||
@@ -121,10 +136,13 @@ export class LearningOrchestrator {
|
||||
throw error;
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
void this.runGate({
|
||||
job.promise = this.runGate({
|
||||
input,
|
||||
job,
|
||||
recentTurns: transcript.turns.slice(-config.LEARNING_REVIEW_MAX_RECENT_TURNS),
|
||||
turnCount,
|
||||
}).catch((error) => {
|
||||
logger.warn({ err: error, sessionId: input.sessionId }, "learning job failed");
|
||||
}).finally(() => {
|
||||
this.activeReviews.delete(input.sessionId);
|
||||
});
|
||||
@@ -133,19 +151,24 @@ export class LearningOrchestrator {
|
||||
|
||||
private async runGate({
|
||||
input,
|
||||
job,
|
||||
recentTurns,
|
||||
turnCount,
|
||||
}: {
|
||||
input: TurnReviewInput;
|
||||
job: LearningJob;
|
||||
recentTurns: SessionTurnRecord[];
|
||||
turnCount: number;
|
||||
}) {
|
||||
let gateSessionId: string | null = null;
|
||||
try {
|
||||
if (job.cancelled) return;
|
||||
const gateSession = await this.runtime.createSession(
|
||||
`learning-gate-${input.requestContext.clientSessionId}`,
|
||||
);
|
||||
gateSessionId = gateSession.id;
|
||||
job.sessions.add(gateSession.id);
|
||||
if (job.cancelled) return;
|
||||
setRuntimeSessionContext({
|
||||
actorKey: input.requestContext.actorKey,
|
||||
allowLearningWrite: false,
|
||||
@@ -162,13 +185,14 @@ export class LearningOrchestrator {
|
||||
toRuntimeModel(config.LEARNING_GATE_MODEL),
|
||||
);
|
||||
const messages = await this.runtime.messages(gateSession.id, 20);
|
||||
if (job.cancelled) return;
|
||||
const assistantMessage = [...messages]
|
||||
.reverse()
|
||||
.find((message) => message.info.role === "assistant");
|
||||
const gateText = collectTextContent(assistantMessage?.parts ?? []);
|
||||
const gate = parseGateResult(gateText);
|
||||
if (!gate) {
|
||||
await this.sessionLearningStateStore.completeGate(input.sessionId, turnCount);
|
||||
await this.sessionLearningStateStore.completeGate(learningContext(input), turnCount);
|
||||
await writeLearningAuditLog({
|
||||
action: "review-gate",
|
||||
detail: "gate result was not valid JSON",
|
||||
@@ -193,12 +217,13 @@ export class LearningOrchestrator {
|
||||
traceId: input.requestContext.traceId,
|
||||
});
|
||||
if (!shouldPromote) {
|
||||
await this.sessionLearningStateStore.completeGate(input.sessionId, turnCount);
|
||||
await this.sessionLearningStateStore.completeGate(learningContext(input), turnCount);
|
||||
return;
|
||||
}
|
||||
await this.runReview({
|
||||
focus: gate.focus,
|
||||
input,
|
||||
job,
|
||||
recentTurns,
|
||||
turnCount,
|
||||
});
|
||||
@@ -215,7 +240,10 @@ export class LearningOrchestrator {
|
||||
} finally {
|
||||
if (gateSessionId) {
|
||||
removeRuntimeSessionContext(gateSessionId);
|
||||
await this.runtime.abortSession(gateSessionId).catch(() => undefined);
|
||||
await this.runtime.removeSession(gateSessionId).catch((error) => {
|
||||
logger.warn({ err: error, sessionId: gateSessionId }, "failed to remove learning gate session");
|
||||
});
|
||||
job.sessions.delete(gateSessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -223,17 +251,21 @@ export class LearningOrchestrator {
|
||||
private async runReview({
|
||||
focus,
|
||||
input,
|
||||
job,
|
||||
recentTurns,
|
||||
turnCount,
|
||||
}: {
|
||||
focus: GateResult["focus"];
|
||||
input: TurnReviewInput;
|
||||
job: LearningJob;
|
||||
recentTurns: SessionTurnRecord[];
|
||||
turnCount: number;
|
||||
}) {
|
||||
if (job.cancelled) return;
|
||||
const reviewSession = await this.runtime.createSession(
|
||||
`learning-review-${input.requestContext.clientSessionId}`,
|
||||
);
|
||||
job.sessions.add(reviewSession.id);
|
||||
setRuntimeSessionContext({
|
||||
actorKey: input.requestContext.actorKey,
|
||||
allowLearningWrite: false,
|
||||
@@ -245,20 +277,23 @@ export class LearningOrchestrator {
|
||||
traceId: input.requestContext.traceId,
|
||||
});
|
||||
try {
|
||||
if (job.cancelled) return;
|
||||
const existingMemory = await this.loadMemoryContext(input.requestContext);
|
||||
if (job.cancelled) return;
|
||||
await this.runtime.prompt(
|
||||
reviewSession.id,
|
||||
buildReviewPrompt({ existingMemory, focus, recentTurns }),
|
||||
toRuntimeModel(input.model),
|
||||
);
|
||||
const messages = await this.runtime.messages(reviewSession.id, 20);
|
||||
if (job.cancelled) return;
|
||||
const assistantMessage = [...messages]
|
||||
.reverse()
|
||||
.find((message) => message.info.role === "assistant");
|
||||
const reviewText = collectTextContent(assistantMessage?.parts ?? []);
|
||||
const parsed = parseReviewResult(reviewText);
|
||||
if (!parsed) {
|
||||
await this.sessionLearningStateStore.completeGate(input.sessionId, turnCount);
|
||||
await this.sessionLearningStateStore.completeGate(learningContext(input), turnCount);
|
||||
await writeLearningAuditLog({
|
||||
action: "review-parse",
|
||||
detail: "review result was not valid JSON",
|
||||
@@ -269,8 +304,8 @@ export class LearningOrchestrator {
|
||||
});
|
||||
return;
|
||||
}
|
||||
await this.applyReviewResult(input, parsed, turnCount);
|
||||
await this.sessionLearningStateStore.completeReview(input.sessionId, turnCount);
|
||||
await this.applyReviewResult(input, parsed, turnCount, job);
|
||||
if (!job.cancelled) await this.sessionLearningStateStore.completeReview(learningContext(input), turnCount);
|
||||
} catch (error) {
|
||||
logger.warn({ err: error, sessionId: input.sessionId }, "learning review failed");
|
||||
await writeLearningAuditLog({
|
||||
@@ -283,7 +318,10 @@ export class LearningOrchestrator {
|
||||
});
|
||||
} finally {
|
||||
removeRuntimeSessionContext(reviewSession.id);
|
||||
await this.runtime.abortSession(reviewSession.id).catch(() => undefined);
|
||||
await this.runtime.removeSession(reviewSession.id).catch((error) => {
|
||||
logger.warn({ err: error, sessionId: reviewSession.id }, "failed to remove learning review session");
|
||||
});
|
||||
job.sessions.delete(reviewSession.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,14 +329,17 @@ export class LearningOrchestrator {
|
||||
input: TurnReviewInput,
|
||||
result: ReviewResult,
|
||||
turnCount: number,
|
||||
job: LearningJob,
|
||||
) {
|
||||
const threshold = config.LEARNING_MIN_PROPOSAL_CONFIDENCE;
|
||||
let accepted = 0;
|
||||
for (const proposal of result.memories) {
|
||||
if (job.cancelled) return;
|
||||
const outcome = await this.applyMemoryProposal(input, proposal, threshold);
|
||||
accepted += outcome ? 1 : 0;
|
||||
}
|
||||
for (const proposal of result.skills) {
|
||||
if (job.cancelled) return;
|
||||
const outcome = await this.applySkillProposal(input, proposal, threshold);
|
||||
accepted += outcome ? 1 : 0;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { join } from "node:path";
|
||||
import { basename, join } from "node:path";
|
||||
import { lstat } from "node:fs/promises";
|
||||
|
||||
import { config } from "../config.js";
|
||||
import { type SessionWorkspaceContext } from "../runtime/conversationWorkspace.js";
|
||||
import { type SessionWorkspaceStore } from "../sessions/workspace-store.js";
|
||||
import {
|
||||
atomicWriteJson,
|
||||
ensureDirectory,
|
||||
@@ -15,15 +18,31 @@ export type SessionLearningState = {
|
||||
};
|
||||
|
||||
export class SessionLearningStateStore {
|
||||
constructor(private readonly baseDir = config.SESSION_LEARNING_STATE_STORAGE_DIR) {}
|
||||
constructor(private readonly baseDir = config.SESSION_LEARNING_STATE_STORAGE_DIR, private readonly workspaces?: SessionWorkspaceStore) {}
|
||||
|
||||
async initialize() {
|
||||
await ensureDirectory(this.baseDir);
|
||||
if (!this.workspaces) await ensureDirectory(this.baseDir);
|
||||
}
|
||||
|
||||
async read(sessionId: string): Promise<SessionLearningState> {
|
||||
const existing = await readJsonFile<SessionLearningState>(this.filePath(sessionId));
|
||||
async read(context: SessionWorkspaceContext): Promise<SessionLearningState> {
|
||||
const sessionId = context.sessionId;
|
||||
if (this.workspaces) {
|
||||
await this.workspaces.ensure(context);
|
||||
await this.workspaces.validate(context, "learning.json");
|
||||
}
|
||||
let existing = await readJsonFile<SessionLearningState>(this.filePath(context));
|
||||
if (!existing && this.workspaces) {
|
||||
const legacy = this.legacyPath(sessionId);
|
||||
try {
|
||||
if ((await lstat(legacy)).isSymbolicLink()) throw new Error("legacy learning state must not be a symbolic link");
|
||||
existing = await readJsonFile<SessionLearningState>(legacy);
|
||||
if (existing?.sessionId !== sessionId) throw new Error("learning state session mismatch");
|
||||
await this.workspaces.writeRecord(context, "learning.json", existing);
|
||||
await this.workspaces.archiveLegacyRecord(context, "learning.json", legacy);
|
||||
} catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
||||
}
|
||||
if (existing) {
|
||||
if (existing.sessionId !== sessionId) throw new Error("learning state session mismatch");
|
||||
return {
|
||||
lastGatedTurn: existing.lastGatedTurn,
|
||||
lastReviewedTurn: existing.lastReviewedTurn,
|
||||
@@ -39,31 +58,39 @@ export class SessionLearningStateStore {
|
||||
};
|
||||
}
|
||||
|
||||
async write(state: SessionLearningState) {
|
||||
await atomicWriteJson(this.filePath(state.sessionId), {
|
||||
async write(context: SessionWorkspaceContext, state: SessionLearningState) {
|
||||
if (state.sessionId !== context.sessionId) throw new Error("learning state session mismatch");
|
||||
const next = {
|
||||
...state,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
if (this.workspaces) await this.workspaces.writeRecord(context, "learning.json", next);
|
||||
else await atomicWriteJson(this.filePath(context), next);
|
||||
}
|
||||
|
||||
async completeReview(sessionId: string, reviewedTurnCount: number) {
|
||||
const current = await this.read(sessionId);
|
||||
await this.write({
|
||||
async completeReview(context: SessionWorkspaceContext, reviewedTurnCount: number) {
|
||||
const current = await this.read(context);
|
||||
await this.write(context, {
|
||||
...current,
|
||||
lastGatedTurn: Math.max(current.lastGatedTurn, reviewedTurnCount),
|
||||
lastReviewedTurn: reviewedTurnCount,
|
||||
});
|
||||
}
|
||||
|
||||
async completeGate(sessionId: string, gatedTurnCount: number) {
|
||||
const current = await this.read(sessionId);
|
||||
await this.write({
|
||||
async completeGate(context: SessionWorkspaceContext, gatedTurnCount: number) {
|
||||
const current = await this.read(context);
|
||||
await this.write(context, {
|
||||
...current,
|
||||
lastGatedTurn: gatedTurnCount,
|
||||
});
|
||||
}
|
||||
|
||||
private filePath(sessionId: string) {
|
||||
private filePath(context: SessionWorkspaceContext) {
|
||||
return this.workspaces ? this.workspaces.path(context, "learning.json") : this.legacyPath(context.sessionId);
|
||||
}
|
||||
|
||||
private legacyPath(sessionId: string) {
|
||||
if (basename(`${sessionId}.json`) !== `${sessionId}.json` || sessionId.includes("\\")) throw new Error("invalid learning session id");
|
||||
return join(this.baseDir, `${sessionId}.json`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user