Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Added an ACP semantic-edges-v1 producer: each agent session appends an append-only `semantic-edges.jsonl` ledger beside its session artifacts, every provider turn and compaction summary call carries one opaque request ID on `X-ACP-Model-Request-ID` and `Idempotency-Key` (minted before the call, committed or failed when its stream resolves, and stable across retry attempts of the same call body), spawned subagents record their parent session and spawning request while successful children record their return, and `deriveSemanticEdges` folds a session tree's ledgers into commit-gated `continuation`/`subagent_call`/`subagent_return`/`compaction` edges matching the verifiers semantic-edges-v1 schema. Derivation only — nothing publishes or reads the ledger yet.
2 changes: 2 additions & 0 deletions packages/coding-agent/src/core/agent-session-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,8 @@ export class AgentSessionRuntime implements SubagentRuntimeHost {
rlmSessionDir: options.sessionDir,
rlmParentNodeId: options.rlmParentNodeId,
rlmParentAgent: options.parentSession.sessionName ?? options.parentSession.sessionId,
semanticParentSessionId: options.parentSession.sessionId,
semanticSpawnedByRequestId: options.spawnedByRequestId,
},
runtimeMetadata: {
kind: "subagent",
Expand Down
4 changes: 4 additions & 0 deletions packages/coding-agent/src/core/agent-session-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ export interface AgentSessionCreationOptions {
rlmSessionDir?: string;
rlmParentNodeId?: string;
rlmParentAgent?: string;
semanticParentSessionId?: string;
semanticSpawnedByRequestId?: string;
subagentRuntimeHost?: SubagentRuntimeHost;
rlmHeartbeatController?: AgentRlmHeartbeatController;
prewarmIpythonKernel?: boolean;
Expand Down Expand Up @@ -251,6 +253,8 @@ export async function createAgentSessionFromServices(
rlmSessionDir: options.rlmSessionDir,
rlmParentNodeId: options.rlmParentNodeId,
rlmParentAgent: options.rlmParentAgent,
semanticParentSessionId: options.semanticParentSessionId,
semanticSpawnedByRequestId: options.semanticSpawnedByRequestId,
subagentRuntimeHost: options.subagentRuntimeHost,
rlmHeartbeatController: options.rlmHeartbeatController,
sessionStartEvent: options.sessionStartEvent,
Expand Down
162 changes: 134 additions & 28 deletions packages/coding-agent/src/core/agent-session.ts
Comment thread
snimu marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,12 @@ import {
type RlmSubagentRuntime,
type SubagentRuntimeHost,
} from "./rlm-runtime.js";
import {
modelRequestHeaders,
SEMANTIC_EDGES_LEDGER_FILENAME,
SemanticEdgeRecorder,
wrapStreamFnWithSemanticEdges,
} from "./semantic-edges.js";
import {
ActionStore,
type ActionTicket,
Expand Down Expand Up @@ -442,6 +448,8 @@ export interface AgentSessionConfig {
rlmSessionDir?: string;
rlmParentNodeId?: string;
rlmParentAgent?: string;
semanticParentSessionId?: string;
semanticSpawnedByRequestId?: string;
subagentRuntimeHost?: SubagentRuntimeHost;
autonomous?: AgentAutonomousConfig;
prewarmIpythonKernel?: boolean;
Expand Down Expand Up @@ -1155,6 +1163,7 @@ export class AgentSession {
private _rlmMaxDepth: number;
private _rlmMaxDepthSource: RlmMaxDepthSource;
private _rlmSessionDir?: string;
private readonly _semanticEdges: SemanticEdgeRecorder;
private _rlmParentNodeId?: string;
private _rlmParentAgent?: string;
private _repliedToParentSinceTask: boolean | undefined;
Expand Down Expand Up @@ -1264,6 +1273,14 @@ export class AgentSession {
this._rlmSessionDir = config.rlmSessionDir;
this._rlmParentNodeId = config.rlmParentNodeId;
this._rlmParentAgent = config.rlmParentAgent;
const semanticEdgesDir = this._rlmSessionDir ?? this.sessionManager.getSessionArtifactDir();
this._semanticEdges = new SemanticEdgeRecorder({
ledgerPath: semanticEdgesDir ? join(semanticEdgesDir, SEMANTIC_EDGES_LEDGER_FILENAME) : undefined,
sessionId: this.sessionManager.getSessionId(),
parentSessionId: config.semanticParentSessionId,
spawnedByRequestId: config.semanticSpawnedByRequestId,
});
this.agent.streamFn = wrapStreamFnWithSemanticEdges(this.agent.streamFn, this._semanticEdges);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Comment thread
snimu marked this conversation as resolved.
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
// A resumed child may have replied before this process started; false would
// claim knowledge that is not present in the session transcript.
this._repliedToParentSinceTask =
Expand Down Expand Up @@ -3703,6 +3720,7 @@ export class AgentSession {
}

private _resolveRetry(): void {
this._semanticEdges.clearTurnRetry();
if (this._retryResolve) {
this._retryResolve();
this._retryResolve = undefined;
Expand Down Expand Up @@ -4262,6 +4280,10 @@ export class AgentSession {
return this._rlmDepth;
}

get semanticEdges(): SemanticEdgeRecorder {
return this._semanticEdges;
}

get rlmMaxDepth(): number {
return this._rlmMaxDepth;
}
Expand Down Expand Up @@ -7459,41 +7481,101 @@ export class AgentSession {
let extensionCompaction: CompactionResult | undefined;
let fromExtension = false;

if (this._extensionRunner.hasHandlers("session_before_compact")) {
const result = (await this._extensionRunner.emit({
type: "session_before_compact",
preparation,
branchEntries: pathEntries,
customInstructions,
signal,
})) as SessionBeforeCompactResult | undefined;
const semanticCompaction = this._semanticEdges.beginCompaction();
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
let compactionRecorded = false;
let summary: string;
let firstKeptEntryId: string;
let tokensBefore: number;
let details: CompactionResult["details"];
try {
if (this._extensionRunner.hasHandlers("session_before_compact")) {
const result = (await this._extensionRunner.emit({
type: "session_before_compact",
preparation,
branchEntries: pathEntries,
customInstructions,
signal,
})) as SessionBeforeCompactResult | undefined;

if (result?.cancel) {
throw new Error("Compaction cancelled");
if (result?.cancel) {
throw new Error("Compaction cancelled");
}

if (result?.compaction) {
extensionCompaction = result.compaction;
fromExtension = true;
}
}

if (result?.compaction) {
extensionCompaction = result.compaction;
fromExtension = true;
if (extensionCompaction) {
({ summary, firstKeptEntryId, tokensBefore, details } = extensionCompaction);
} else {
// Each summary wire call gets its own request ID: split turns send two
// different bodies, and one Idempotency-Key must never cover both.
const summaryCall = async <T>(
call: (callHeaders: Record<string, string> | undefined) => Promise<T>,
): Promise<T> => {
const requestId = this._semanticEdges.startCompactionRequest(semanticCompaction.compactionId);
let result: T;
try {
result = await call({ ...headers, ...modelRequestHeaders(requestId) });
} catch (error) {
try {
this._semanticEdges.failRequest(requestId);
} catch (ledgerError) {
// Keep the original summarization error; provenance is best-effort.
console.warn(`semantic-edge ledger write failed: ${String(ledgerError)}`);
}
throw error;
}
this._semanticEdges.finishRequest(requestId);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
Comment thread
snimu marked this conversation as resolved.
Outdated
return result;
};
({ summary, firstKeptEntryId, tokensBefore, details } = await compact(
preparation,
model,
apiKey,
headers,
customInstructions,
signal,
this.thinkingLevel,
summaryCall,
));
}
}

const { summary, firstKeptEntryId, tokensBefore, details } =
extensionCompaction ??
(await compact(preparation, model, apiKey, headers, customInstructions, signal, this.thinkingLevel));
if (signal.aborted) {
throw new Error("Compaction cancelled");
Comment thread
cursor[bot] marked this conversation as resolved.
}

if (signal.aborted) {
throw new Error("Compaction cancelled");
// Ledger-before-effect: the compaction outcome is durable before the transcript
// commits it. Marked first: the ID is consumed even when the write throws, and a
// second finish attempt would mask the original I/O error.
compactionRecorded = true;
this._semanticEdges.finishCompaction(semanticCompaction.compactionId, "completed");
this.sessionManager.appendCompaction(
summary,
firstKeptEntryId,
tokensBefore,
details,
fromExtension,
customInstructions,
);
} catch (error) {
if (!compactionRecorded) {
const cancelled =
error instanceof Error && (error.name === "AbortError" || error.message === "Compaction cancelled");
try {
this._semanticEdges.finishCompaction(
semanticCompaction.compactionId,
cancelled ? "cancelled" : "failed",
);
} catch (ledgerError) {
// Keep the original compaction error; provenance is best-effort.
console.warn(`semantic-edge ledger write failed: ${String(ledgerError)}`);
}
}
throw error;
}

this.sessionManager.appendCompaction(
summary,
firstKeptEntryId,
tokensBefore,
details,
fromExtension,
customInstructions,
);
const newEntries = this.sessionManager.getEntries();
this.agent.state.messages = this.sessionManager.buildSessionContext().messages;
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
this._mergeUnpersistedOutcomes(this.agent.state.messages);
Expand Down Expand Up @@ -9353,6 +9435,7 @@ export class AgentSession {
sessionDir: string;
model: Model<any>;
thinkingLevel?: ThinkingLevel;
spawnedByRequestId?: string;
}): CreateRlmSubagentRuntimeOptions {
return {
parentSession: this,
Expand All @@ -9375,6 +9458,7 @@ export class AgentSession {
rlmDepth: this._rlmDepth + 1,
rlmMaxDepth: this._rlmMaxDepth,
rlmParentNodeId: options.id,
spawnedByRequestId: options.spawnedByRequestId,
};
}

Expand Down Expand Up @@ -9440,6 +9524,8 @@ export class AgentSession {
rlmSessionDir: options.sessionDir,
rlmParentNodeId: options.rlmParentNodeId,
rlmParentAgent: options.parentSession.sessionName ?? options.parentSession.sessionId,
semanticParentSessionId: options.parentSession.sessionId,
semanticSpawnedByRequestId: options.spawnedByRequestId,
sessionStartEvent: { type: "session_start", reason: "startup" },
});
if (child.sessionName !== options.sessionName) {
Expand Down Expand Up @@ -10243,6 +10329,8 @@ export class AgentSession {
kwargs: Record<string, unknown> = {},
spawnCode?: string,
): Promise<RlmSpawnHandle> {
// Snapshot before any await: the spawning request is the turn whose tool call is executing now.
const spawnedByRequestId = this._semanticEdges.lastTurnRequestId;
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
const { name: rawName, model: rawModel, thinking: rawThinking, ...unsupported } = kwargs;
const unsupportedKwargs = Object.keys(unsupported);
if (unsupportedKwargs.length > 0) {
Expand Down Expand Up @@ -10356,6 +10444,7 @@ export class AgentSession {
sessionDir: childSessionDir,
model: modelSelection.model,
thinkingLevel: requestedThinkingLevel,
spawnedByRequestId,
}),
onSessionPublished: publishChildSession,
};
Expand Down Expand Up @@ -10501,6 +10590,11 @@ export class AgentSession {
await child.waitForRlmQuiescence();
if (run.error) throw new Error(run.error);
run.status = "done";
// Only successful completions return; the edge lands on the parent's next commit.
const childLastCommitted = child.semanticEdges.lastCommittedRequestId;
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
if (childLastCommitted !== undefined) {
this._semanticEdges.recordChildReturned(child.sessionId, childLastCommitted);
}
Comment thread
snimu marked this conversation as resolved.
durationMs = Date.now() - startedAt;
activity = undefined;
emitChildUpdate();
Expand Down Expand Up @@ -10535,6 +10629,13 @@ export class AgentSession {
run.status = "error";
run.error = runError.message;
}
// A failed child still returns an error outcome the parent consumes;
// cancelled runs and zero-commit children return nothing.
const failedChild = childSession ?? childRuntime?.session;
const failedLastCommitted = failedChild?.semanticEdges.lastCommittedRequestId;
if (run.status === "error" && failedChild && failedLastCommitted !== undefined) {
this._semanticEdges.recordChildReturned(failedChild.sessionId, failedLastCommitted);
}
durationMs = Date.now() - startedAt;
activity = undefined;
emitChildUpdate();
Expand Down Expand Up @@ -10849,6 +10950,11 @@ export class AgentSession {
}

const delayMs = settings.baseDelayMs * 2 ** (this._retryAttempt - 1);
// Park now: the retry re-issues the failed call and must reuse its Idempotency-Key.
// Payload hooks mutate the wire body after the hash point, so reuse is forfeited.
if (!this._extensionRunner.hasHandlers("before_provider_request")) {
this._semanticEdges.prepareTurnRetry();
}

this._emit({
type: "auto_retry_start",
Expand Down
65 changes: 39 additions & 26 deletions packages/coding-agent/src/core/compaction/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,11 @@ Be concise. Focus on what's needed to understand the kept suffix.`;
* @param preparation - Pre-calculated preparation from prepareCompaction()
* @param customInstructions - Optional custom focus for the summary
*/
/** Runs one summary wire call; hosts decorate each call with its own request identity. */
export type SummaryCallRunner = <T>(
call: (callHeaders: Record<string, string> | undefined) => Promise<T>,
) => Promise<T>;

export async function compact(
preparation: CompactionPreparation,
model: Model<any>,
Expand All @@ -678,6 +683,7 @@ export async function compact(
customInstructions?: string,
signal?: AbortSignal,
thinkingLevel?: ThinkingLevel,
summaryCall: SummaryCallRunner = (call) => call(headers),
): Promise<CompactionResult> {
const {
firstKeptEntryId,
Expand All @@ -692,42 +698,49 @@ export async function compact(
let summary: string;

if (isSplitTurn && turnPrefixMessages.length > 0) {
// Split turns make two wire calls with different bodies; each needs its own identity.
const [historyResult, turnPrefixResult] = await Promise.all([
messagesToSummarize.length > 0
? generateSummary(
messagesToSummarize,
model,
settings.reserveTokens,
apiKey,
headers,
signal,
customInstructions,
previousSummary,
thinkingLevel,
? summaryCall((callHeaders) =>
generateSummary(
messagesToSummarize,
model,
settings.reserveTokens,
apiKey,
callHeaders,
signal,
customInstructions,
previousSummary,
thinkingLevel,
),
)
: Promise.resolve("No prior history."),
generateTurnPrefixSummary(
turnPrefixMessages,
summaryCall((callHeaders) =>
generateTurnPrefixSummary(
turnPrefixMessages,
model,
settings.reserveTokens,
apiKey,
callHeaders,
signal,
thinkingLevel,
),
),
]);
summary = `${historyResult}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult}`;
} else {
summary = await summaryCall((callHeaders) =>
generateSummary(
messagesToSummarize,
model,
settings.reserveTokens,
apiKey,
headers,
callHeaders,
signal,
customInstructions,
previousSummary,
thinkingLevel,
),
]);
summary = `${historyResult}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult}`;
} else {
summary = await generateSummary(
messagesToSummarize,
model,
settings.reserveTokens,
apiKey,
headers,
signal,
customInstructions,
previousSummary,
thinkingLevel,
);
}
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
Expand Down
Loading
Loading