- onTurnError returns Promise<boolean|undefined> signaling credential switch - agent-session returns markUsageLimitReached().switched from the hook - runtime retries once when switched===true, otherwise preserves quota pause - advisor-config uses liveStat model provider for bare (slash-less) selectors - 2 regression tests: switched=true (retry succeeds) and switched=false (pause)
617 lines
23 KiB
TypeScript
617 lines
23 KiB
TypeScript
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
|
import { estimateTokens } from "@oh-my-pi/pi-agent-core/compaction";
|
|
import type { AssistantMessage, ImageContent, TextContent } from "@oh-my-pi/pi-ai";
|
|
import { logger } from "@oh-my-pi/pi-utils";
|
|
import { obfuscateToolArguments, type SecretObfuscator } from "../secrets/obfuscator";
|
|
import { formatSessionHistoryMarkdown, PRIMARY_CONTEXT_CUSTOM_TYPES } from "../session/session-history-format";
|
|
|
|
/**
|
|
* Minimal slice of `Agent` the runtime drives — satisfied by pi-agent-core
|
|
* `Agent`. `state.error` mirrors `Agent.state.error`: provider/stream failures
|
|
* the loop catches internally never reject `prompt()`, so the runtime reads
|
|
* this field after every prompt to detect a failed turn.
|
|
*/
|
|
export interface AdvisorAgent {
|
|
prompt(input: string): Promise<void>;
|
|
abort(reason?: unknown): void;
|
|
reset(): void;
|
|
/**
|
|
* Drop messages appended past `count`. Called after a failed `prompt()` so a
|
|
* retry doesn't replay the failed user batch + synthetic assistant-error
|
|
* turn `Agent.#runLoop` records on its internal state.
|
|
*/
|
|
rollbackTo?(count: number): void;
|
|
readonly state: { messages: AgentMessage[]; error?: string };
|
|
}
|
|
|
|
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;
|
|
/** Redact primary transcript bytes before they reach the advisor model. */
|
|
obfuscator?: SecretObfuscator;
|
|
/**
|
|
* 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>;
|
|
/**
|
|
* Called immediately before each `agent.prompt(batch)` cycle. Lets the host
|
|
* clear per-update advisor state — currently the one-advise-per-update gate
|
|
* in {@link AdvisorEmissionGuard}, which the host owns because it is the
|
|
* one that routes `advise()` results back to the primary.
|
|
*/
|
|
beginAdvisorUpdate?(): void;
|
|
/**
|
|
* Called with the error of every failed advisor turn, before the retry sleep
|
|
* or the dropped-after-3 path. Lets the host apply credential-level remedies
|
|
* the advisor loop lacks: the in-stream a/b/c auth retry rotates through
|
|
* sibling credentials within one request but never blocks the LAST failing
|
|
* one — the primary agent's retry pipeline does that via
|
|
* `markUsageLimitReached`, so without this hook the advisor re-picks the
|
|
* same usage-limited account on every retry. Errors thrown here are logged
|
|
* and swallowed.
|
|
*/
|
|
onTurnError?(error: unknown): Promise<boolean | undefined> | undefined;
|
|
/** Surface a non-recovering advisor failure to the host UI without adding model-visible context. */
|
|
notifyFailure?(error: unknown): void;
|
|
/** Signal that the advisor hit a quota/rate-limit. The host should update
|
|
* the status indicator to `quota_exhausted`; the runtime will auto-resume
|
|
* after its cooldown elapses. */
|
|
notifyQuotaExhausted?(): void;
|
|
}
|
|
|
|
interface PendingDelta {
|
|
text: string;
|
|
turns: number;
|
|
}
|
|
|
|
interface CatchupWaiter {
|
|
threshold: number;
|
|
resolve: () => void;
|
|
finish: () => void;
|
|
timer?: NodeJS.Timeout;
|
|
}
|
|
|
|
/**
|
|
* Classify a provider error as quota/rate-limit exhaustion. These errors are
|
|
* transient but persist for minutes (until the quota window resets), so the
|
|
* runtime pauses the advisor and auto-resumes after a cooldown instead of
|
|
* burning 3 retries and dropping the backlog.
|
|
*/
|
|
function isQuotaError(err: unknown): boolean {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
return /\b(quota|rate.?limit|429|insufficient_quota|usage.?limit|credit)\b/i.test(msg);
|
|
}
|
|
export class AdvisorRuntime {
|
|
#lastCount = 0;
|
|
/** Last-shown body, keyed by primary-context customType (plan/goal mode rules,
|
|
* approved plan). These prompts are re-injected verbatim every primary turn;
|
|
* this lets {@link #renderDelta} collapse an unchanged copy to a one-line
|
|
* marker so the advisor isn't re-fed the full ~1k-token rules each turn.
|
|
* Cleared on every re-prime/seed and when a failed batch is dropped. */
|
|
#seenContext = new Map<string, string>();
|
|
#pending: PendingDelta[] = [];
|
|
#busy = false;
|
|
#backlog = 0;
|
|
#consecutiveFailures = 0;
|
|
#failureNotified = false;
|
|
#latestMessages?: AgentMessage[];
|
|
#waiters: CatchupWaiter[] = [];
|
|
/** Bumped by every external {@link reset}/{@link dispose}. A drain iteration
|
|
* captures it before its awaits; a mismatch on resume means a reset aborted
|
|
* the in-flight advisor prompt, so the stale batch is dropped instead of
|
|
* being retried/requeued into the post-reset conversation. */
|
|
#epoch = 0;
|
|
disposed = false;
|
|
/** Quota/rate-limit pause state. When `true`, the advisor stops processing
|
|
* turns and drops new deltas until an explicit {@link reset} clears it
|
|
* (triggered by `/new`, config rebuild, or session restart). There is no
|
|
* timer-based auto-resume: provider quota windows (5h/7d) are far longer
|
|
* than any reasonable timer, and premature retries waste calls and
|
|
* re-trigger the same error. */
|
|
#quotaExhausted = false;
|
|
|
|
constructor(
|
|
private readonly agent: AdvisorAgent,
|
|
private readonly host: AdvisorRuntimeHost,
|
|
private readonly retryDelayMs = 1000,
|
|
) {}
|
|
|
|
get backlog(): number {
|
|
return this.#backlog;
|
|
}
|
|
get quotaExhausted(): boolean {
|
|
return this.#quotaExhausted;
|
|
}
|
|
get failureNotified(): boolean {
|
|
return this.#failureNotified;
|
|
}
|
|
|
|
onTurnEnd(messages?: AgentMessage[]): void {
|
|
if (this.disposed || this.#quotaExhausted) 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 || this.#quotaExhausted)
|
|
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.#epoch++;
|
|
this.#pending = [];
|
|
this.#backlog = 0;
|
|
this.#consecutiveFailures = 0;
|
|
this.#failureNotified = false;
|
|
this.#wakeAllWaiters();
|
|
try {
|
|
this.agent.abort("advisor disposed");
|
|
} catch {}
|
|
}
|
|
|
|
#resetAdvisorContext(clearBacklog: boolean, wakeWaiters: boolean): void {
|
|
this.#lastCount = 0;
|
|
this.#pending = [];
|
|
this.#consecutiveFailures = 0;
|
|
this.#failureNotified = false;
|
|
this.#seenContext.clear();
|
|
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.#epoch++;
|
|
this.#quotaExhausted = false;
|
|
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.#failureNotified = false;
|
|
this.#seenContext.clear();
|
|
this.#wakeAllWaiters();
|
|
}
|
|
|
|
#renderDelta(messages?: AgentMessage[]): string | null {
|
|
const all = messages ?? this.#latestMessages ?? this.host.snapshotMessages();
|
|
if (all.length < this.#lastCount) {
|
|
this.#lastCount = all.length;
|
|
this.#seenContext.clear();
|
|
return null;
|
|
}
|
|
const delta = all
|
|
.slice(this.#lastCount)
|
|
.filter(m => !(m.role === "custom" && (m as { customType?: string }).customType === "advisor"))
|
|
.map(m => this.#dedupContextMessage(m));
|
|
this.#lastCount = all.length;
|
|
if (delta.length === 0) return null;
|
|
const obfuscator = this.host.obfuscator;
|
|
const formattedDelta = obfuscator?.hasSecrets() ? obfuscateAdvisorDelta(obfuscator, delta) : delta;
|
|
const md = formatSessionHistoryMarkdown(formattedDelta, {
|
|
includeThinking: true,
|
|
includeToolIntent: true,
|
|
watchedRoles: true,
|
|
expandPrimaryContext: true,
|
|
expandEditDiffs: true,
|
|
});
|
|
if (!md.trim()) return null;
|
|
return `### Session update\n\n${md}`;
|
|
}
|
|
|
|
/**
|
|
* Collapse a re-injected primary-context prompt (plan/goal mode rules, the
|
|
* approved plan) to a short marker when its body is byte-identical to the
|
|
* copy already shown to the advisor since the last re-prime. The primary
|
|
* re-injects these verbatim every turn; without this the advisor re-reads the
|
|
* full rules (~1k tokens) each turn. Returns a CLONE when collapsing — the
|
|
* input shares the live primary transcript and must never be mutated.
|
|
*/
|
|
#dedupContextMessage(msg: AgentMessage): AgentMessage {
|
|
if (msg.role !== "custom") return msg;
|
|
const type = (msg as { customType?: string }).customType;
|
|
if (!type || !PRIMARY_CONTEXT_CUSTOM_TYPES.has(type)) return msg;
|
|
const content = (msg as { content?: unknown }).content;
|
|
if (typeof content !== "string") return msg;
|
|
if (this.#seenContext.get(type) === content) {
|
|
return { ...(msg as object), content: "(unchanged — still in effect)" } as AgentMessage;
|
|
}
|
|
this.#seenContext.set(type, content);
|
|
return msg;
|
|
}
|
|
|
|
#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();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Drop the user batch + synthetic assistant-error turn `Agent.#runLoop`
|
|
* appended for a failed prompt so a retry replays a clean baseline and the
|
|
* dropped-after-3 path never leaks orphan failures into the next successful
|
|
* run. Prefers the agent's own `rollbackTo` (which also re-syncs its
|
|
* append-only context); falls back to truncating `state.messages` for tests
|
|
* that hand-roll a minimal facade.
|
|
*/
|
|
#rollbackFailedTurn(snapshot: number): void {
|
|
const messages = this.agent.state.messages;
|
|
if (messages.length <= snapshot) return;
|
|
try {
|
|
if (this.agent.rollbackTo) {
|
|
this.agent.rollbackTo(snapshot);
|
|
return;
|
|
}
|
|
messages.length = snapshot;
|
|
} catch (err) {
|
|
logger.debug("advisor rollback failed", { err: String(err) });
|
|
}
|
|
}
|
|
|
|
async #drain(): Promise<void> {
|
|
if (this.#busy) return;
|
|
this.#busy = true;
|
|
try {
|
|
while (!this.disposed && this.#pending.length) {
|
|
const popped = this.#pending.splice(0);
|
|
const epoch = this.#epoch;
|
|
// 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) });
|
|
}
|
|
}
|
|
// A reset/dispose during context maintenance invalidates this batch.
|
|
if (this.#epoch !== epoch) continue;
|
|
|
|
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;
|
|
// Capture the advisor's message count BEFORE the prompt so a failure can
|
|
// roll back the user batch + synthetic assistant-error turn `Agent.#runLoop`
|
|
// appends to internal state. Without this, a retry would replay the
|
|
// failed batch on top of the stale turns and the dropped-after-3 path
|
|
// would leak orphan failures into the next successful run's context.
|
|
const messageSnapshot = this.agent.state.messages.length;
|
|
try {
|
|
// Reset the host's per-update advisor state (one-advise-per-update
|
|
// gate) before each model cycle, so the new batch starts with a
|
|
// fresh budget. Dedupe history persists across cycles.
|
|
this.host.beginAdvisorUpdate?.();
|
|
await this.agent.prompt(batch);
|
|
// `Agent.#runLoop` catches provider/stream failures internally and
|
|
// resolves `prompt()` cleanly with the assistant turn ending in
|
|
// `stopReason: "error"` and the message recorded on `state.error`.
|
|
// Treat that as a failed turn so OpenRouter ZDR-style endpoint
|
|
// rejections trip the retry/notify path instead of looking like a
|
|
// successful empty cycle.
|
|
const promptError = this.agent.state.error;
|
|
if (promptError) throw new Error(promptError);
|
|
success = true;
|
|
this.#consecutiveFailures = 0;
|
|
this.#failureNotified = false;
|
|
} catch (err) {
|
|
if (this.#epoch !== epoch) continue;
|
|
this.#rollbackFailedTurn(messageSnapshot);
|
|
if (isQuotaError(err)) {
|
|
logger.warn("advisor quota exhausted", { err: String(err) });
|
|
// Call the usage-limit hook so AgentSession can block the
|
|
// exhausted credential via markUsageLimitReached before pausing.
|
|
// When the hook returns true, a sibling credential is available
|
|
// now — retry immediately instead of entering quota pause.
|
|
let switched = false;
|
|
try {
|
|
switched = (await this.host.onTurnError?.(err)) === true;
|
|
} catch (hookErr) {
|
|
logger.debug("advisor onTurnError hook failed", { err: String(hookErr) });
|
|
}
|
|
if (switched) {
|
|
// Sibling credential available — retry once with the new key.
|
|
const retrySnapshot = this.agent.state.messages.length;
|
|
try {
|
|
this.host.beginAdvisorUpdate?.();
|
|
await this.agent.prompt(batch);
|
|
const retryError = this.agent.state.error;
|
|
if (retryError) throw new Error(retryError);
|
|
success = true;
|
|
this.#consecutiveFailures = 0;
|
|
this.#failureNotified = false;
|
|
} catch {
|
|
this.#rollbackFailedTurn(retrySnapshot);
|
|
if (this.#epoch !== epoch) continue;
|
|
this.#quotaExhausted = true;
|
|
this.#consecutiveFailures = 0;
|
|
this.#failureNotified = false;
|
|
this.#seenContext.clear();
|
|
this.#pending.unshift({ text: batch, turns: finalTurns });
|
|
this.#wakeAllWaiters();
|
|
try {
|
|
this.host.notifyQuotaExhausted?.();
|
|
} catch (notifyErr) {
|
|
logger.warn("advisor quota notification failed", { err: String(notifyErr) });
|
|
}
|
|
break;
|
|
}
|
|
} else {
|
|
this.#quotaExhausted = true;
|
|
this.#consecutiveFailures = 0;
|
|
this.#failureNotified = false;
|
|
this.#seenContext.clear();
|
|
this.#pending.unshift({ text: batch, turns: finalTurns });
|
|
// Release catchup waiters: a quota-paused advisor can't make
|
|
// progress, so waitForCatchup must not block the primary agent.
|
|
this.#wakeAllWaiters();
|
|
try {
|
|
this.host.notifyQuotaExhausted?.();
|
|
} catch (notifyErr) {
|
|
logger.warn("advisor quota notification failed", { err: String(notifyErr) });
|
|
}
|
|
break;
|
|
}
|
|
} else {
|
|
logger.debug("advisor turn failed", { err: String(err) });
|
|
try {
|
|
await this.host.onTurnError?.(err);
|
|
} catch (hookErr) {
|
|
logger.debug("advisor onTurnError hook failed", { err: String(hookErr) });
|
|
}
|
|
// The hook awaits; a reset during it invalidates this batch like the
|
|
// prompt await above — drop it instead of requeueing stale content.
|
|
if (this.#epoch !== epoch) continue;
|
|
this.#consecutiveFailures++;
|
|
if (this.#consecutiveFailures >= 3) {
|
|
logger.warn("advisor failed consecutively 3 times; dropping backlog to prevent stall");
|
|
if (!this.#failureNotified) {
|
|
this.#failureNotified = true;
|
|
try {
|
|
this.host.notifyFailure?.(err);
|
|
} catch (notifyErr) {
|
|
logger.warn("advisor failure notification failed", { err: String(notifyErr) });
|
|
}
|
|
}
|
|
this.#consecutiveFailures = 0;
|
|
this.#seenContext.clear();
|
|
success = true;
|
|
} else {
|
|
this.#pending.unshift({ text: batch, turns: finalTurns });
|
|
await Bun.sleep(this.retryDelayMs);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (success && this.#epoch === epoch) {
|
|
this.#backlog = Math.max(0, this.#backlog - finalTurns);
|
|
this.#notifyWaiters();
|
|
}
|
|
}
|
|
} finally {
|
|
this.#busy = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
type TextualContent = string | readonly (TextContent | ImageContent)[];
|
|
|
|
function obfuscateTextualContent(obfuscator: SecretObfuscator, content: TextualContent): TextualContent {
|
|
if (typeof content === "string") return obfuscator.obfuscate(content);
|
|
let changed = false;
|
|
const result = content.map((block): TextContent | ImageContent => {
|
|
if (block.type !== "text") return block;
|
|
const text = obfuscator.obfuscate(block.text);
|
|
if (text === block.text) return block;
|
|
changed = true;
|
|
return { ...block, text };
|
|
});
|
|
return changed ? result : content;
|
|
}
|
|
|
|
function obfuscateAssistantMessage(obfuscator: SecretObfuscator, message: AssistantMessage): AssistantMessage {
|
|
let changed = false;
|
|
const content = message.content.map((block): AssistantMessage["content"][number] => {
|
|
if (block.type === "text") {
|
|
const text = obfuscator.obfuscate(block.text);
|
|
if (text === block.text) return block;
|
|
changed = true;
|
|
return { ...block, text };
|
|
}
|
|
if (block.type === "toolCall") {
|
|
const args = obfuscateToolArguments(obfuscator, block.arguments);
|
|
if (args === block.arguments) return block;
|
|
changed = true;
|
|
return { ...block, arguments: args };
|
|
}
|
|
return block;
|
|
});
|
|
return changed ? { ...message, content } : message;
|
|
}
|
|
|
|
function obfuscateDetails(
|
|
obfuscator: SecretObfuscator,
|
|
details: Record<string, unknown> | undefined,
|
|
): Record<string, unknown> | undefined {
|
|
if (!details) return details;
|
|
// Walk strings at every depth: `customOneLiner` renders nested fields
|
|
// (e.g. `async-result` reads `details.jobs[].label`/`jobId`), so a shallow
|
|
// pass leaks any secret a background job's label happens to contain.
|
|
return obfuscateToolArguments(obfuscator, details);
|
|
}
|
|
|
|
function obfuscateAdvisorMessage(obfuscator: SecretObfuscator, message: AgentMessage): AgentMessage {
|
|
switch (message.role) {
|
|
case "user":
|
|
case "developer": {
|
|
const content = obfuscateTextualContent(obfuscator, message.content as TextualContent);
|
|
return content === message.content ? message : ({ ...(message as object), content } as AgentMessage);
|
|
}
|
|
case "toolResult": {
|
|
const msg = message as AgentMessage & {
|
|
content: TextualContent;
|
|
details?: Record<string, unknown>;
|
|
};
|
|
const content = obfuscateTextualContent(obfuscator, msg.content);
|
|
const details = obfuscateDetails(obfuscator, msg.details);
|
|
if (content === msg.content && details === msg.details) return message;
|
|
return { ...(message as object), content, details } as AgentMessage;
|
|
}
|
|
case "assistant":
|
|
return obfuscateAssistantMessage(obfuscator, message as AssistantMessage) as AgentMessage;
|
|
case "custom":
|
|
case "hookMessage": {
|
|
const msg = message as AgentMessage & {
|
|
content: TextualContent;
|
|
details?: Record<string, unknown>;
|
|
};
|
|
const content = obfuscateTextualContent(obfuscator, msg.content);
|
|
const details = obfuscateDetails(obfuscator, msg.details);
|
|
if (content === msg.content && details === msg.details) return message;
|
|
return { ...(message as object), content, details } as AgentMessage;
|
|
}
|
|
case "bashExecution": {
|
|
const msg = message as AgentMessage & { command: string; output: string };
|
|
const command = obfuscator.obfuscate(msg.command);
|
|
const output = obfuscator.obfuscate(msg.output);
|
|
return command === msg.command && output === msg.output
|
|
? message
|
|
: ({ ...(message as object), command, output } as AgentMessage);
|
|
}
|
|
case "pythonExecution": {
|
|
const msg = message as AgentMessage & { code: string; output: string };
|
|
const code = obfuscator.obfuscate(msg.code);
|
|
const output = obfuscator.obfuscate(msg.output);
|
|
return code === msg.code && output === msg.output
|
|
? message
|
|
: ({ ...(message as object), code, output } as AgentMessage);
|
|
}
|
|
case "branchSummary": {
|
|
const msg = message as AgentMessage & { summary: string };
|
|
const summary = obfuscator.obfuscate(msg.summary);
|
|
return summary === msg.summary ? message : ({ ...(message as object), summary } as AgentMessage);
|
|
}
|
|
case "compactionSummary": {
|
|
const msg = message as AgentMessage & { summary: string };
|
|
const summary = obfuscator.obfuscate(msg.summary);
|
|
return summary === msg.summary ? message : ({ ...(message as object), summary } as AgentMessage);
|
|
}
|
|
case "fileMention": {
|
|
const msg = message as AgentMessage & {
|
|
files: Array<{ path: string; content: string; image?: unknown }>;
|
|
};
|
|
let changed = false;
|
|
const files = msg.files.map(file => {
|
|
const path = obfuscator.obfuscate(file.path);
|
|
const content = obfuscator.obfuscate(file.content);
|
|
if (path === file.path && content === file.content) return file;
|
|
changed = true;
|
|
return { ...file, path, content };
|
|
});
|
|
return changed ? ({ ...(message as object), files } as AgentMessage) : message;
|
|
}
|
|
default:
|
|
return message;
|
|
}
|
|
}
|
|
|
|
function obfuscateAdvisorDelta(obfuscator: SecretObfuscator, messages: AgentMessage[]): AgentMessage[] {
|
|
let changed = false;
|
|
const result = messages.map(message => {
|
|
const next = obfuscateAdvisorMessage(obfuscator, message);
|
|
if (next !== message) changed = true;
|
|
return next;
|
|
});
|
|
return changed ? result : messages;
|
|
}
|