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
+53 -12
View File
@@ -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;
}