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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user