e518bc22c4
- Prevented compaction from reopening a settled terminal answer unless queued work or an active goal remains. - Ran auto-learn capture in an abortable detached agent with constrained tools and isolated provider state. - Replaced primary-turn capture coverage with private-capture regression tests. Fixes #5715
153 lines
6.4 KiB
TypeScript
153 lines
6.4 KiB
TypeScript
/**
|
|
* Auto-learn session controller (experimental).
|
|
*
|
|
* Subscribes to the session event stream and, after a substantive turn,
|
|
* optionally auto-runs a synthetic capture turn. Passive mode is intentionally
|
|
* prompt-cache neutral: the standing system guidance remains available, but no
|
|
* hidden mid-session reminder is inserted into the conversation.
|
|
*
|
|
* Installed once per top-level session (taskDepth 0). The subscription lives
|
|
* for the session's lifetime — `newSession` resets the session in place
|
|
* without re-running startup — so the controller needs no disposal.
|
|
*/
|
|
import { logger } from "@oh-my-pi/pi-utils";
|
|
import type { Settings } from "../config/settings";
|
|
import autolearnGuidance from "../prompts/system/autolearn-guidance.md" with { type: "text" };
|
|
import autolearnGuidanceLearn from "../prompts/system/autolearn-guidance-learn.md" with { type: "text" };
|
|
import autolearnNudgeAutoContinue from "../prompts/system/autolearn-nudge-autocontinue.md" with { type: "text" };
|
|
import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
|
|
|
|
const AUTOLEARN_NUDGE_AUTOCONTINUE = autolearnNudgeAutoContinue.trim();
|
|
const DEFAULT_MIN_TOOL_CALLS = 5;
|
|
|
|
/**
|
|
* Build the standing auto-learn guidance for the system prompt from the tools
|
|
* actually present in the active set, or null when `manage_skill` is absent.
|
|
*
|
|
* Driven by tool presence rather than live settings: the `learn`/`manage_skill`
|
|
* registry is built ONCE at session start (and only for top-level sessions), so
|
|
* keying the guidance on `autolearn.enabled` would let a mid-session enable — or
|
|
* a subagent that filtered the tools out — inject guidance pointing at tools the
|
|
* session never built. The `learn` addendum is included only when the `learn`
|
|
* tool is present (it requires a memory backend).
|
|
*/
|
|
export function buildAutoLearnInstructions(available: { manageSkill: boolean; learn: boolean }): string | null {
|
|
if (!available.manageSkill) return null;
|
|
const parts = [autolearnGuidance.trim()];
|
|
if (available.learn) parts.push(autolearnGuidanceLearn.trim());
|
|
return parts.join("\n\n");
|
|
}
|
|
|
|
export interface AutoLearnControllerOptions {
|
|
session: AgentSession;
|
|
settings: Settings;
|
|
capture: (content: string) => Promise<void>;
|
|
}
|
|
|
|
export class AutoLearnController {
|
|
readonly #session: AgentSession;
|
|
readonly #settings: Settings;
|
|
readonly #capture: (content: string) => Promise<void>;
|
|
#toolCalls = 0;
|
|
/**
|
|
* Whether the in-flight turn BEGAN while goal mode was active. Captured at
|
|
* agent_start because a `goal` tool can complete or drop the goal mid-turn,
|
|
* clearing the live flag before agent_end — so the end-of-turn state alone
|
|
* would let a goal-continuation turn slip through and get nudged.
|
|
*/
|
|
#turnStartedInGoalMode = false;
|
|
/** Prevent overlapping private capture runs while real primary turns continue. */
|
|
#captureInFlight = false;
|
|
/** One newer eligible primary stop arrived while capture was running. */
|
|
#capturePending = false;
|
|
|
|
constructor(options: AutoLearnControllerOptions) {
|
|
this.#session = options.session;
|
|
this.#settings = options.settings;
|
|
this.#capture = options.capture;
|
|
// The listener closure captures `this`, so the session's listener array
|
|
// keeps the controller alive — no stored unsubscribe needed.
|
|
this.#session.subscribe(event => this.#onEvent(event));
|
|
}
|
|
|
|
#onEvent(event: AgentSessionEvent): void {
|
|
if (event.type === "agent_start") {
|
|
// Capture goal-mode state at the turn boundary, before any tool runs.
|
|
this.#turnStartedInGoalMode = this.#session.getGoalModeState()?.enabled === true;
|
|
return;
|
|
}
|
|
if (event.type === "tool_execution_end") {
|
|
this.#toolCalls++;
|
|
return;
|
|
}
|
|
if (event.type === "agent_end") {
|
|
this.#onAgentEnd(event);
|
|
}
|
|
}
|
|
|
|
#onAgentEnd(event: Extract<AgentSessionEvent, { type: "agent_end" }>): void {
|
|
// Snapshot and reset every turn: the counter describes only the
|
|
// just-finished turn, so below-threshold, disabled, and plan-mode stops
|
|
// must not let tool calls accumulate into a later turn.
|
|
const toolCalls = this.#toolCalls;
|
|
this.#toolCalls = 0;
|
|
// Snapshot the turn-start goal flag alongside the counter so a turn that
|
|
// observed no agent_start can never inherit a stale value.
|
|
const startedInGoalMode = this.#turnStartedInGoalMode;
|
|
this.#turnStartedInGoalMode = false;
|
|
|
|
// Never nudge a turn that ended in an abort (ESC, cancel, etc.). The
|
|
// abort flag on the session is unreliable by the time agent_end is
|
|
// deferred to subscribers; read stopReason from the event messages.
|
|
for (let i = event.messages.length - 1; i >= 0; i--) {
|
|
const message = event.messages[i];
|
|
if (message && typeof message === "object" && "role" in message && message.role === "assistant") {
|
|
if ("stopReason" in message && message.stopReason === "aborted") {
|
|
return;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
// Honor a live opt-out: the subscription outlives the setting, so re-check
|
|
// the current flag rather than trusting install-time state.
|
|
if (!this.#settings.get("autolearn.enabled")) return;
|
|
const minToolCalls = this.#settings.get("autolearn.minToolCalls") ?? DEFAULT_MIN_TOOL_CALLS;
|
|
if (toolCalls < minToolCalls) return;
|
|
// Never interrupt plan-mode review.
|
|
if (this.#session.getPlanModeState()?.enabled) return;
|
|
// Never divert a goal loop. Skip when the turn STARTED in goal mode — a
|
|
// `goal` tool may have completed/dropped the goal before this stop — or is
|
|
// still in it: a passive nudge would ride the goal continuation, and
|
|
// auto-continue would compete with it.
|
|
if (startedInGoalMode || this.#session.getGoalModeState()?.enabled) return;
|
|
|
|
// Auto-run a capture turn only when explicitly enabled. Passive mode used to
|
|
// queue a hidden custom message for the next real turn, but that mutates the
|
|
// persisted conversation prefix after providers have cached it. The standing
|
|
// auto-learn system guidance is stable; keep passive mode to that guidance
|
|
// so Anthropic prompt-cache prefixes survive long sessions.
|
|
const autoContinue = this.#settings.get("autolearn.autoContinue") === true;
|
|
if (!autoContinue) return;
|
|
|
|
if (this.#captureInFlight) {
|
|
this.#capturePending = true;
|
|
return;
|
|
}
|
|
this.#startCapture();
|
|
}
|
|
|
|
#startCapture(): void {
|
|
this.#captureInFlight = true;
|
|
void this.#capture(AUTOLEARN_NUDGE_AUTOCONTINUE)
|
|
.catch(err => {
|
|
logger.warn("auto-learn capture failed", { err });
|
|
})
|
|
.finally(() => {
|
|
this.#captureInFlight = false;
|
|
if (!this.#capturePending) return;
|
|
this.#capturePending = false;
|
|
this.#startCapture();
|
|
});
|
|
}
|
|
}
|