diff --git a/packages/core/src/lifecycle-manager.ts b/packages/core/src/lifecycle-manager.ts index 3cae2f77e6..6b1e7249f2 100644 --- a/packages/core/src/lifecycle-manager.ts +++ b/packages/core/src/lifecycle-manager.ts @@ -34,6 +34,8 @@ import { type SCM, type Notifier, type Session, + type AgentMemoryEntry, + type Tracker, type CanonicalSessionLifecycle, type EventPriority, type ProjectConfig as _ProjectConfig, @@ -2306,6 +2308,104 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan } } + async function flushAgentMemory( + session: Session, + _oldStatus: SessionStatus, + newStatus: SessionStatus, + ): Promise { + const shouldFlush = + newStatus === SESSION_STATUS.STUCK || + newStatus === SESSION_STATUS.ERRORED || + newStatus === SESSION_STATUS.KILLED; + if (!shouldFlush) return; + if (!session.issueId) return; + + const project = config.projects[session.projectId]; + if (!project?.tracker?.plugin) return; + + const tracker = registry.get("tracker", project.tracker.plugin); + if (!tracker?.readMemory || !tracker?.writeMemory) return; + + const runtime = registry.get("runtime", project.runtime ?? config.defaults.runtime); + + let outputDigest: string | undefined; + if (runtime && session.runtimeHandle) { + try { + const output = await runtime.getOutput(session.runtimeHandle, 50); + if (output) outputDigest = output.slice(-500); + } catch { /* non-critical */ } + } + + const agentName = session.metadata["agent"]; + const agent = agentName ? registry.get("agent", agentName) : null; + let tried = "No summary available"; + if (agent) { + try { + const info = await agent.getSessionInfo(session); + if (info?.summary) tried = info.summary; + } catch { /* non-critical */ } + } + + let nextSteps: string | undefined; + let failedAt: string | undefined; + if (outputDigest) { + const nextMatch = outputDigest.match(/(?:Next:|TODO:|next step[s]?:|I should|Try:)\s*(.+)/i); + if (nextMatch?.[1]) nextSteps = nextMatch[1].trim().slice(0, 300); + const errorMatch = outputDigest.match(/(?:Error:|error:|Failed:|FAILED|Exception:|❌)\s*(.+)/); + if (errorMatch?.[1]) failedAt = errorMatch[1].trim().slice(0, 300); + } + + let attemptNumber = 1; + try { + const prior = await tracker.readMemory(session.issueId, project); + attemptNumber = prior.length + 1; + } catch { /* default to 1 */ } + + const entry: AgentMemoryEntry = { + attempt: attemptNumber, + agentId: session.id, + startedAt: session.createdAt, + finishedAt: new Date(), + status: newStatus === SESSION_STATUS.STUCK ? "stuck" + : newStatus === SESSION_STATUS.KILLED ? "killed" + : "failed", + tried, + failedAt, + nextSteps, + outputDigest, + }; + + try { + await tracker.writeMemory(session.issueId, entry, project); + + const existingLog = session.metadata["agentMemoryLog"]; + const existingEntries: AgentMemoryEntry[] = existingLog + ? (JSON.parse(existingLog) as AgentMemoryEntry[]) + : []; + existingEntries.push(entry); + updateSessionMetadata(session, { agentMemoryLog: JSON.stringify(existingEntries) }); + + recordActivityEvent({ + projectId: session.projectId, + sessionId: session.id, + source: "lifecycle", + kind: "lifecycle.transition", + summary: `agent memory flushed for attempt #${attemptNumber}`, + data: { issueId: session.issueId, attempt: attemptNumber, status: entry.status }, + }); + } catch (err) { + recordActivityEvent({ + projectId: session.projectId, + sessionId: session.id, + source: "lifecycle", + kind: "lifecycle.transition", + level: "warn", + summary: "agent memory flush failed", + data: { errorMessage: err instanceof Error ? err.message : String(err) }, + }); + } + } + /** * When a session's PR is merged, tear down its tmux runtime, remove its * worktree, and archive its metadata. Guarded by an idleness check so we @@ -2754,6 +2854,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan maybeDispatchReviewBacklog(session, oldStatus, newStatus, transitionReaction), maybeDispatchMergeConflicts(session, newStatus), maybeDispatchCIFailureDetails(session, oldStatus, newStatus, transitionReaction), + flushAgentMemory(session, oldStatus, newStatus), ]); // Report watcher: audit agent reports for issues (#140) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 87cbd6b0c5..8f251ceaa6 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -17,6 +17,23 @@ import type { ObservabilityLevel } from "./observability.js"; * 8. Lifecycle Manager (core, not pluggable) */ + +// ============================================================================= +// AGENT MEMORY +// ============================================================================= + +export interface AgentMemoryEntry { + attempt: number; + agentId: string; + startedAt: Date; + finishedAt: Date; + status: "failed" | "stuck" | "killed"; + tried: string; + failedAt?: string; + nextSteps?: string; + outputDigest?: string; +} + // ============================================================================= // SESSION // ============================================================================= @@ -746,6 +763,11 @@ export interface Tracker { * error message. */ preflight?(context: PreflightContext): Promise; + /** Read all prior agent memory entries from the issue */ + readMemory?(identifier: string, project: ProjectConfig): Promise; + + /** Append a new memory entry to the issue as a structured comment */ + writeMemory?(identifier: string, entry: AgentMemoryEntry, project: ProjectConfig): Promise; } export interface Issue { @@ -758,6 +780,7 @@ export interface Issue { assignee?: string; priority?: number; branchName?: string; + agentMemory?: AgentMemoryEntry[]; } export interface IssueFilters { @@ -1584,12 +1607,12 @@ export interface ProjectConfig { orchestratorRules?: string; orchestratorSessionStrategy?: - | "reuse" - | "delete" - | "ignore" - | "delete-new" - | "ignore-new" - | "kill-previous"; + | "reuse" + | "delete" + | "ignore" + | "delete-new" + | "ignore-new" + | "kill-previous"; opencodeIssueSessionStrategy?: "reuse" | "delete" | "ignore"; } diff --git a/packages/plugins/tracker-github/src/index.ts b/packages/plugins/tracker-github/src/index.ts index 90cb94183c..e4a356ed17 100644 --- a/packages/plugins/tracker-github/src/index.ts +++ b/packages/plugins/tracker-github/src/index.ts @@ -14,6 +14,7 @@ import { type IssueUpdate, type CreateIssueInput, type ProjectConfig, + type AgentMemoryEntry, } from "@aoagents/ao-core"; // --------------------------------------------------------------------------- @@ -276,6 +277,39 @@ function createGitHubTracker(): Tracker { lines.push("## Description", "", issue.description); } + // Inject prior agent memory if any exists + let memory: AgentMemoryEntry[] = []; + try { + memory = await tracker.readMemory!(identifier, project); + } catch { /* non-critical: missing memory should not block prompt generation */ } + if (memory.length > 0) { + lines.push( + "", + "=== PRIOR AGENT ATTEMPTS ===", + `This task has been attempted ${memory.length} time(s) before. Read carefully before starting.`, + "", + ); + for (const entry of memory) { + lines.push(`Attempt #${entry.attempt} — Status: ${entry.status.toUpperCase()}`); + lines.push(` Started: ${new Date(entry.startedAt).toISOString()}`); + lines.push(` Tried: ${entry.tried}`); + if (entry.failedAt) { + lines.push(` Failed at: ${entry.failedAt}`); + } + if (entry.nextSteps) { + lines.push(` Next steps: ${entry.nextSteps}`); + } + if (entry.outputDigest) { + lines.push(` Last output:\n${entry.outputDigest}`); + } + lines.push(""); + } + lines.push( + "Do NOT repeat what failed. Start from the next steps left by the last agent.", + "============================", + ); + } + lines.push( "", "The issue title, description, and labels above are current. Fetch comments or linked issues via `gh` only if you need additional context beyond what is provided here.", @@ -438,13 +472,62 @@ function createGitHubTracker(): Tracker { }, async preflight(): Promise { - // Memoize across plugins: tracker-github and scm-github both check the - // same gh CLI / auth state. Sharing key "gh-cli-auth" via process-cache - // means both plugins' preflights resolve to the same in-flight check - // (or cached result) — halving execs on the happy path and giving one - // error message instead of two on the failure path. await checkGhCliAuth(); }, + + async readMemory( + identifier: string, + project: ProjectConfig, + ): Promise { + const repo = requireRepo(project); + const raw = await gh([ + "issue", + "comment", + "list", + identifier, + "--repo", + repo, + "--json", + "body", + ]); + + const comments: Array<{ body: string }> = JSON.parse(raw); + const MARKER = "$/, "") + .trim(); + return [JSON.parse(json) as AgentMemoryEntry]; + } catch { + return []; + } + }) + .sort((a, b) => new Date(a.startedAt).getTime() - new Date(b.startedAt).getTime()) + .map((entry, index) => ({ ...entry, attempt: index + 1 })); + }, + + async writeMemory( + identifier: string, + entry: AgentMemoryEntry, + project: ProjectConfig, + ): Promise { + const repo = requireRepo(project); + const body = ``; + await gh([ + "issue", + "comment", + "--repo", + repo, + identifier, + "--body", + body, + ]); + }, }; return tracker; diff --git a/packages/plugins/tracker-github/test/index.test.ts b/packages/plugins/tracker-github/test/index.test.ts index 16f40b88f4..1618ed5dbb 100644 --- a/packages/plugins/tracker-github/test/index.test.ts +++ b/packages/plugins/tracker-github/test/index.test.ts @@ -1,5 +1,24 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; +// --------------------------------------------------------------------------- +// Set up dummy gh CLI path for test environment +// --------------------------------------------------------------------------- +vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { writeFileSync, mkdirSync } = require("node:fs"); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { join } = require("node:path"); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { tmpdir } = require("node:os"); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { randomUUID } = require("node:crypto"); + + const tempBinDir = join(tmpdir(), `ao-test-gh-bin-${randomUUID()}`); + mkdirSync(tempBinDir, { recursive: true }); + writeFileSync(join(tempBinDir, process.platform === "win32" ? "gh.cmd" : "gh"), "@echo off"); + process.env.PATH = `${tempBinDir}${process.platform === "win32" ? ";" : ":"}${process.env.PATH}`; +}); + // --------------------------------------------------------------------------- // Mock node:child_process // --------------------------------------------------------------------------- @@ -318,6 +337,7 @@ describe("tracker-github plugin", () => { describe("generatePrompt", () => { it("includes title and URL", async () => { mockGh(sampleIssue); + mockGh([]); const prompt = await tracker.generatePrompt("123", project); expect(prompt).toContain("Fix login bug"); expect(prompt).toContain("https://github.com/acme/repo/issues/123"); @@ -326,24 +346,28 @@ describe("tracker-github plugin", () => { it("includes labels when present", async () => { mockGh(sampleIssue); + mockGh([]); const prompt = await tracker.generatePrompt("123", project); expect(prompt).toContain("bug, priority-high"); }); it("includes description", async () => { mockGh(sampleIssue); + mockGh([]); const prompt = await tracker.generatePrompt("123", project); expect(prompt).toContain("Users can't log in with SSO"); }); it("omits labels section when no labels", async () => { mockGh({ ...sampleIssue, labels: [] }); + mockGh([]); const prompt = await tracker.generatePrompt("123", project); expect(prompt).not.toContain("Labels:"); }); it("omits description section when body is empty", async () => { mockGh({ ...sampleIssue, body: null }); + mockGh([]); const prompt = await tracker.generatePrompt("123", project); expect(prompt).not.toContain("## Description"); }); @@ -570,6 +594,70 @@ describe("tracker-github plugin", () => { }); }); + // ---- readMemory / writeMemory ------------------------------------------- + + describe("readMemory / writeMemory", () => { + it("readMemory parses and sorts memory comments from issue", async () => { + const mockComments = [ + { body: "Normal user comment" }, + { + body: "", + }, + { + body: "", + }, + ]; + mockGh(mockComments); + + const memory = await tracker.readMemory!("123", project); + + expect(memory).toHaveLength(2); + expect(memory[0]).toEqual({ + attempt: 1, + agentId: "app-1", + status: "stuck", + }); + expect(memory[1]).toEqual({ + attempt: 2, + agentId: "app-1", + status: "killed", + }); + expect(ghMock).toHaveBeenCalledWith( + expect.stringMatching(/(?:^|[\\/])gh(?:\.(?:exe|cmd|bat))?$/i), + ["issue", "comment", "list", "123", "--repo", "acme/repo", "--json", "body"], + expect.any(Object), + ); + }); + + it("writeMemory appends memory entry comment to issue", async () => { + ghMock.mockResolvedValueOnce({ stdout: "" }); + const entry = { + attempt: 1, + agentId: "app-1", + status: "stuck" as const, + tried: "tried something", + startedAt: new Date("2026-05-22T22:00:00Z"), + finishedAt: new Date("2026-05-22T22:05:00Z"), + }; + + await tracker.writeMemory!("123", entry, project); + + expect(ghMock).toHaveBeenCalledWith( + expect.stringMatching(/(?:^|[\\/])gh(?:\.(?:exe|cmd|bat))?$/i), + [ + "issue", + "comment", + "--repo", + "acme/repo", + "123", + "--body", + ``, + ], + expect.any(Object), + ); + }); + }); + describe("preflight", () => { const ctx: PreflightContext = { project, diff --git a/packages/web/src/components/AgentMemoryPanel.tsx b/packages/web/src/components/AgentMemoryPanel.tsx new file mode 100644 index 0000000000..88e4b49d2d --- /dev/null +++ b/packages/web/src/components/AgentMemoryPanel.tsx @@ -0,0 +1,123 @@ +"use client"; + +import type { DashboardSession } from "@/lib/types"; + +interface AgentMemoryEntry { + attempt: number; + agentId: string; + startedAt: string; + finishedAt: string; + status: "failed" | "stuck" | "killed"; + tried: string; + failedAt?: string; + nextSteps?: string; + outputDigest?: string; +} + +const STATUS_STYLES: Record = { + failed: { dot: "bg-[var(--color-danger)]", label: "Failed" }, + stuck: { dot: "bg-[var(--color-warning)]", label: "Stuck" }, + killed: { dot: "bg-[var(--color-text-tertiary)]", label: "Killed" }, +}; + +function formatTime(iso: string): string { + try { + return new Date(iso).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + } catch { + return iso; + } +} + +interface AgentMemoryPanelProps { + session: DashboardSession; +} + +export function AgentMemoryPanel({ session }: AgentMemoryPanelProps) { + const raw = session.metadata?.agentMemoryLog; + if (!raw) return null; + + const entries: AgentMemoryEntry[] = (() => { + try { + return JSON.parse(raw) as AgentMemoryEntry[]; + } catch { + return []; + } + })(); + + if (entries.length === 0) return null; + + return ( +
+
+ + + + + + Agent memory — {entries.length} attempt{entries.length !== 1 ? "s" : ""} + +
+ +
+ {entries.map((entry) => { + const style = STATUS_STYLES[entry.status] ?? STATUS_STYLES.failed; + return ( +
+
+ + + Attempt #{entry.attempt} + + + {style.label} + + + {formatTime(entry.startedAt)} – {formatTime(entry.finishedAt)} + +
+ +
+
+ Tried: + {entry.tried} +
+ + {entry.failedAt && ( +
+ Failed at: + + {entry.failedAt} + +
+ )} + + {entry.nextSteps && ( +
+ Next steps: + {entry.nextSteps} +
+ )} +
+
+ ); + })} +
+
+ ); +} diff --git a/packages/web/src/components/SessionDetail.tsx b/packages/web/src/components/SessionDetail.tsx index 5ea560e434..7a68048bb8 100644 --- a/packages/web/src/components/SessionDetail.tsx +++ b/packages/web/src/components/SessionDetail.tsx @@ -17,6 +17,7 @@ import { projectDashboardPath, projectSessionPath } from "@/lib/routes"; import { MobileBottomNav } from "./MobileBottomNav"; import { SessionDetailHeader, type OrchestratorZones } from "./SessionDetailHeader"; import { SessionEndedSummary } from "./SessionEndedSummary"; +import { AgentMemoryPanel } from "./AgentMemoryPanel"; import { sessionActivityMeta } from "./session-detail-utils"; export type { OrchestratorZones } from "./SessionDetailHeader"; @@ -143,14 +144,17 @@ export function SessionDetail({ {!showTerminal ? (
) : terminalEnded ? ( - +
+ + +
) : (