Files
oh-my-pi/packages/coding-agent/src/advisor/runtime.ts
T
can1357 5bce7ed6df feat: added advisory transcript formatting and one-shot benchmark metrics
- Introduced advisory note output as `<advisory>` tags with optional severity and guidance.
- Updated session transcript formatting to `### Session update` and inline watched role labels.
- Added shared `escapeXmlText` utility and escaped XML-sensitive text in advisor outputs.
- Added one-shot success run token metrics and one-shot statistics reporting.
2026-06-16 18:34:50 +02:00

256 lines
7.4 KiB
TypeScript

import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import { estimateTokens } from "@oh-my-pi/pi-agent-core/compaction";
import { logger } from "@oh-my-pi/pi-utils";
import { formatSessionHistoryMarkdown } from "../session/session-history-format";
/** Minimal slice of `Agent` the runtime drives — satisfied by pi-agent-core `Agent`. */
export interface AdvisorAgent {
prompt(input: string): Promise<void>;
abort(reason?: unknown): void;
reset(): void;
readonly state: { messages: AgentMessage[] };
}
export interface AdvisorRuntimeHost {
/** Live primary transcript (use `agent.state.messages`). */
snapshotMessages(): AgentMessage[];
/** Surface one advice note to the primary (enqueues into the session YieldQueue). */
enqueueAdvice(note: string, severity?: "nit" | "concern" | "blocker"): void;
/**
* Pre-prompt context maintenance for the advisor's own append-only context.
* Promotes the advisor model to a larger sibling when its context nears the
* window (mirroring the primary's promote-first policy) and resolves `true`
* when the advisor should re-prime — reset and replay the current
* primary-bounded transcript — because promotion did not free enough room.
* Optional: hosts that omit it get no maintenance (context only shrinks when
* the primary's next compaction triggers {@link AdvisorRuntime.reset}).
*/
maintainContext?(incomingTokens: number): Promise<boolean>;
}
interface PendingDelta {
text: string;
turns: number;
}
interface CatchupWaiter {
threshold: number;
resolve: () => void;
finish: () => void;
timer?: NodeJS.Timeout;
}
export class AdvisorRuntime {
#lastCount = 0;
#pending: PendingDelta[] = [];
#busy = false;
#backlog = 0;
#consecutiveFailures = 0;
#latestMessages?: AgentMessage[];
#waiters: CatchupWaiter[] = [];
disposed = false;
constructor(
private readonly agent: AdvisorAgent,
private readonly host: AdvisorRuntimeHost,
private readonly retryDelayMs = 1000,
) {}
get backlog(): number {
return this.#backlog;
}
onTurnEnd(messages?: AgentMessage[]): void {
if (this.disposed) return;
const all = messages ?? this.host.snapshotMessages();
this.#latestMessages = all;
const render = this.#renderDelta(all);
if (render) {
this.#pending.push({ text: render, turns: 1 });
this.#backlog++;
this.#notifyWaiters();
void this.#drain();
}
}
waitForCatchup(maxMs: number, threshold: number, signal?: AbortSignal): Promise<void> {
if (this.disposed || signal?.aborted || this.#backlog < threshold) return Promise.resolve();
const { promise, resolve } = Promise.withResolvers<void>();
let waiter!: CatchupWaiter;
const finish = (): void => {
const idx = this.#waiters.indexOf(waiter);
if (idx >= 0) this.#waiters.splice(idx, 1);
clearTimeout(waiter.timer);
signal?.removeEventListener("abort", finish);
resolve();
};
waiter = { threshold, resolve, finish, timer: setTimeout(finish, maxMs) };
this.#waiters.push(waiter);
signal?.addEventListener("abort", finish, { once: true });
if (signal?.aborted) {
finish();
}
return promise;
}
dispose(): void {
this.disposed = true;
this.#pending = [];
this.#backlog = 0;
this.#consecutiveFailures = 0;
this.#wakeAllWaiters();
try {
this.agent.abort("advisor disposed");
} catch {}
}
#resetAdvisorContext(clearBacklog: boolean, wakeWaiters: boolean): void {
this.#lastCount = 0;
this.#pending = [];
this.#consecutiveFailures = 0;
if (clearBacklog) {
this.#backlog = 0;
}
if (wakeWaiters) {
this.#wakeAllWaiters();
}
try {
this.agent.reset();
} catch {}
try {
this.agent.abort("advisor reset");
} catch {}
}
/**
* Re-prime the advisor after a history rewrite (compaction, session
* switch/resume, branch). Clears the advisor's own (non-persisted) context
* and rewinds the cursor to 0 so the NEXT turn replays the full current —
* post-compaction — transcript, giving the advisor fresh context instead of
* leaving it blind to everything before the rewrite.
*/
reset(): void {
this.#resetAdvisorContext(true, true);
}
/**
* Seed the cursor to the current transcript length when the advisor is enabled
* mid-session. Prevents the next turn from replaying the entire history to the
* advisor (which would be expensive and likely stale).
*/
seedTo(count: number): void {
this.#lastCount = count;
this.#pending = [];
this.#backlog = 0;
this.#consecutiveFailures = 0;
this.#wakeAllWaiters();
}
#renderDelta(messages?: AgentMessage[]): string | null {
const all = messages ?? this.#latestMessages ?? this.host.snapshotMessages();
if (all.length < this.#lastCount) {
this.#lastCount = all.length;
return null;
}
const delta = all
.slice(this.#lastCount)
.filter(m => !(m.role === "custom" && (m as { customType?: string }).customType === "advisor"));
this.#lastCount = all.length;
if (delta.length === 0) return null;
const md = formatSessionHistoryMarkdown(delta, {
includeThinking: true,
includeToolIntent: true,
watchedRoles: true,
});
if (!md.trim()) return null;
return `### Session update\n\n${md}`;
}
#notifyWaiters(): void {
for (let i = this.#waiters.length - 1; i >= 0; i--) {
const w = this.#waiters[i];
if (this.#backlog < w.threshold) {
w.finish();
}
}
}
#wakeAllWaiters(): void {
for (const w of [...this.#waiters]) {
w.finish();
}
}
async #drain(): Promise<void> {
if (this.#busy) return;
this.#busy = true;
try {
while (!this.disposed && this.#pending.length) {
const popped = this.#pending.splice(0);
// Each delta already opens with a `### Session update` heading, so
// join with a blank line rather than a `---` rule.
const candidateBatch = popped.map(b => b.text).join("\n\n");
const turnsCovered = popped.reduce((sum, b) => sum + b.turns, 0);
const incomingTokens = estimateTokens({
role: "user",
content: candidateBatch,
timestamp: Date.now(),
});
let shouldReprime = false;
if (this.host.maintainContext) {
try {
shouldReprime = await this.host.maintainContext(incomingTokens);
} catch (err) {
logger.debug("advisor context maintenance failed", { err: String(err) });
}
}
let batch: string | null;
let finalTurns: number;
if (shouldReprime) {
// Promotion could not fit the advisor's context — re-prime.
const newTurns = this.#pending.reduce((sum, b) => sum + b.turns, 0);
this.#resetAdvisorContext(false, false);
batch = this.#renderDelta(this.#latestMessages);
finalTurns = turnsCovered + newTurns;
} else {
batch = candidateBatch;
finalTurns = turnsCovered;
}
if (this.disposed || batch === null) {
this.#backlog = Math.max(0, this.#backlog - finalTurns);
this.#notifyWaiters();
continue;
}
let success = false;
try {
await this.agent.prompt(batch);
success = true;
this.#consecutiveFailures = 0;
} catch (err) {
logger.debug("advisor turn failed", { err: String(err) });
this.#consecutiveFailures++;
if (this.#consecutiveFailures >= 3) {
logger.warn("advisor failed consecutively 3 times; dropping backlog to prevent stall");
this.#consecutiveFailures = 0;
success = true;
} else {
this.#pending.unshift({ text: batch, turns: finalTurns });
await Bun.sleep(this.retryDelayMs);
}
}
if (success) {
this.#backlog = Math.max(0, this.#backlog - finalTurns);
this.#notifyWaiters();
}
}
} finally {
this.#busy = false;
}
}
}