Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions packages/core/src/lifecycle-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ import {
type SCM,
type Notifier,
type Session,
type AgentMemoryEntry,
type Tracker,
type CanonicalSessionLifecycle,
type EventPriority,
type ProjectConfig as _ProjectConfig,
Expand Down Expand Up @@ -2306,6 +2308,104 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
}
}

async function flushAgentMemory(
session: Session,
_oldStatus: SessionStatus,
newStatus: SessionStatus,
): Promise<void> {
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>("tracker", project.tracker.plugin);
if (!tracker?.readMemory || !tracker?.writeMemory) return;

const runtime = registry.get<Runtime>("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>("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);
Comment thread
LaithMimi marked this conversation as resolved.
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
Expand Down Expand Up @@ -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)
Expand Down
35 changes: 29 additions & 6 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// =============================================================================
Expand Down Expand Up @@ -746,6 +763,11 @@ export interface Tracker {
* error message.
*/
preflight?(context: PreflightContext): Promise<void>;
/** Read all prior agent memory entries from the issue */
readMemory?(identifier: string, project: ProjectConfig): Promise<AgentMemoryEntry[]>;

/** Append a new memory entry to the issue as a structured comment */
writeMemory?(identifier: string, entry: AgentMemoryEntry, project: ProjectConfig): Promise<void>;
}

export interface Issue {
Expand All @@ -758,6 +780,7 @@ export interface Issue {
assignee?: string;
priority?: number;
branchName?: string;
agentMemory?: AgentMemoryEntry[];
}

export interface IssueFilters {
Expand Down Expand Up @@ -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";
}
Expand Down
93 changes: 88 additions & 5 deletions packages/plugins/tracker-github/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
type IssueUpdate,
type CreateIssueInput,
type ProjectConfig,
type AgentMemoryEntry,
} from "@aoagents/ao-core";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -438,13 +472,62 @@ function createGitHubTracker(): Tracker {
},

async preflight(): Promise<void> {
// 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<AgentMemoryEntry[]> {
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 = "<!-- ao-agent-memory";

return comments
.filter((c) => c.body?.startsWith(MARKER))
.flatMap((c) => {
try {
const json = c.body
.replace(MARKER, "")
.replace(/\s*-->$/, "")
.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<void> {
const repo = requireRepo(project);
const body = `<!-- ao-agent-memory\n${JSON.stringify(entry, null, 2)}\n-->`;
await gh([
"issue",
"comment",
"--repo",
repo,
identifier,
"--body",
body,
]);
},
};

return tracker;
Expand Down
Loading