Skip to content
Merged
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
16 changes: 16 additions & 0 deletions .pylon/features.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -335,3 +335,19 @@ decisions:
revisit_when:
- Prime upstream provides an equivalent provider-neutral channel that keeps mutable state and clock-derived content out of the cached tools/system/history prefix, including a declared constraint for append-only-history backends.
- Pylon can drop the fork behavior without regressing measured cache-hit rates through a Claude-Max proxy or Meridian lineage continuation rates.

parent-session-identity:
area: runtime-reliability
state: shipped
owner: pylon-prime-integration
decision: retain
pylon_refs:
- https://github.com/pylon-code/prime-agent/issues/22
- https://github.com/pylon-code/prime-agent/issues/34
upstream_refs:
- https://github.com/PrimeIntellect-ai/prime-agent/tree/c718bf3c30fd8da206ed551837cbb54f7ad15948
fork_change: parent-session-identity-v1
upstream_support: Prime through c718bf3c30fd knows a subagent's parent only as in-memory daemon runtime metadata and as trace headers derived by walking the parentSession file pointer. Its session header has no parentSessionId, ReadonlySessionManager has no getParentSessionId, and parentSession also records fork and new-session lineage, so no extension-visible surface can name the agent that spawned a session.
revisit_when:
- Prime records a spawn-only parent session id in the session header or exposes an equivalent accessor on the extension-visible session manager.
- The proxy-side parent-tree cancellation registry in rynfar/meridian#902 stops needing the parent edge inside the provider payload.
10 changes: 10 additions & 0 deletions .pylon/upstream-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,13 @@ This ledger records Prime upstream evidence and the decision taken for each over
- The fix is a declared per-model constraint, not a workaround. `Model.appendOnlyHistory` marks a backend that caches by session rather than by request prefix and therefore requires the request message array to stay byte-identical to the persisted history. When set, the API registry routes `volatileContext` into the system prompt and adds no payload-only message block; the backend's own session cache absorbs the system-prompt change. Default stays trailing placement, which is correct for direct prefix-cached APIs. The flag is settable from extension `registerProvider` model entries and from `models.json` model definitions and `modelOverrides`, and it is documented for extension authors in `packages/coding-agent/docs/extensions.md`.
- Deferred: the optional debug-level prefix-drift check from issue #26 is not implemented. The regression tests assert the invariant directly, and a cross-turn byte comparison would need new mutable state inside otherwise pure prompt assembly.
- Revisit when Prime upstream offers an equivalent provider-neutral channel that keeps mutable state and clock-derived content out of the cached prefix, and Pylon can drop the fork behavior without regressing measured cache-hit rates through a Claude-Max proxy.

## 2026-08-31 — parent session identity for RLM child extension contexts

- Upstream baseline: `PrimeIntellect-ai/prime-agent@c718bf3c30fd8da206ed551837cbb54f7ad15948`; this addition is client-local and does not advance `reviewed_upstream_commit`.
- Reviewed current upstream `session-manager.ts`, `extensions/runner.ts`, `extensions/types.ts`, `agent-session.ts`, `agent-session-runtime.ts`, `daemon-mode.ts`, and `agent-traces.ts`, plus upstream issue and pull-request searches for `parent_session_id`, `getParentSessionId`, and parent lineage on `before_provider_request`. Upstream tracks a subagent's parent only as in-memory daemon runtime metadata and as an `X-Parent-Session` trace header derived by walking the `parentSession` file pointer. Nothing extension-visible names the spawning agent, and `parentSession` is shared with fork and new-session lineage, so it cannot answer the question on its own.
- `parent-session-identity`: **retain**. The session header gains a spawn-only `parentSessionId`, written by all three RLM child construction paths (inline, `AgentSessionRuntime` host, daemon host). `SessionManager.getParentSessionId()` reads it and joins `ReadonlySessionManager`, so `ctx.sessionManager` exposes it without forking the read-only view. The scoped session view added for child-scoped provider identity delegates the accessor unchanged, so `<sessionId>/side:<id>` and the summarization scopes report their owner's parent.
- Lineage decision: `parentSessionId` names the **immediate** parent, matching the existing trace `parentSessionId` at depth 0. A consumer that holds every session can walk the chain to the root; a root-only field cannot be reconstructed into one.
- Deliberate boundaries: fork, clone, and branch do not carry the linkage, because the result is an independent session with its own provider key rather than a live child of a running parent. Recording the parent id no longer depends on the parent being persisted, which also aligns the inline and `AgentSessionRuntime` hosts with the daemon host's unconditional `newSession` call and its correct child `rlmDepth`.
- Additive only: no daemon command, event, or response shape changes. The parent edge rides inside the `metadata.user_id` envelope extensions already produce.
- Validation: `npm run check` clean. `test/suite/regressions/34-parent-session-identity.test.ts` passes 5/5 and 4 of its 5 cases fail when the inline path stops recording the parent id. Adjacent suites pass: session-manager unit suites, SDK session manager, session flush and git state, migrations, agent traces, context tree, RLM ledger, saved-session catalog, agent-session recursion, child provider identity, subagent runtime host, subagent model selection, subagent terminal messages, side questions, fast-mode children, agent-session runtime, suite compaction, concurrent sessions, daemon agent connection, daemon session id, daemon lazy subagents, daemon session list, ACP RLM subagents, RLM subagent display, clone command, session cwd, and agents-view state — 875 passes across 42 files. `test/extensions-runner.test.ts` fails 21 of 28 identically on unmodified `origin/pylon`, an environment-level extension-loading failure unrelated to this change.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Added `ctx.sessionManager.getParentSessionId()` so extensions can report the session that spawned a subagent, and recorded it in the session header so a resumed subagent still reports its parent ([#34](https://github.com/pylon-code/prime-agent/issues/34)).
28 changes: 28 additions & 0 deletions packages/coding-agent/docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,24 @@ The hook fires for every provider request the session is responsible for, and `c

Treat the id as opaque: match on the `<sessionId>` prefix if you need to group derived work with its session.

`ctx.sessionManager.getParentSessionId()` names the agent that spawned the session, so a handler can stamp the whole tree edge and not just one node:

```typescript
pi.on("before_provider_request", (event, ctx) => {
return {
...event.payload,
metadata: {
user_id: JSON.stringify({
session_id: ctx.sessionManager.getSessionId(),
parent_session_id: ctx.sessionManager.getParentSessionId(),
}),
},
};
});
```

See [ctx.sessionManager](#ctxsessionmanager) for what the parent id does and does not cover.

#### after_provider_response

Fired after an HTTP response is received and before its stream body is consumed. Handlers run in extension load order.
Expand Down Expand Up @@ -916,8 +934,18 @@ For `tool_call`, this state is synchronized through the current assistant messag
ctx.sessionManager.getEntries() // All entries
ctx.sessionManager.getBranch() // Current branch
ctx.sessionManager.getLeafId() // Current leaf entry ID
ctx.sessionManager.getSessionId() // Identity of the conversation this request belongs to
ctx.sessionManager.getParentSessionId() // Session that spawned this one, or undefined
```

`getParentSessionId()` describes the agent tree:

- A root session returns `undefined`. A subagent returns the session id of the agent that spawned it, inline or in the daemon.
- It names the **immediate** parent. A grandchild reports its own parent, not the root, so a handler holding several sessions can walk the chain.
- It is recorded in the session header, so a resumed or reattached subagent still reports its parent.
- Scoped requests (`<sessionId>/side:<id>`, `<sessionId>/compaction`, ...) report the parent of the session that owns them. The scope changes `getSessionId()` only.
- Forking or branching a session does not carry the linkage: the result is an independent session with its own provider identity, not a live child of a running parent.

### ctx.modelRegistry / ctx.model

Access to models and API keys.
Expand Down
6 changes: 6 additions & 0 deletions packages/coding-agent/docs/session-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,12 @@ For sessions with a parent (created via `/fork`, `/clone`, or `newSession({ pare
{"type":"session","version":3,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project","parentSession":"/path/to/original/session.jsonl"}
```

Subagent sessions also record `parentSessionId`, the session id of the agent that spawned them. `parentSession` is a file pointer that fork and new-session lineage share, so only `parentSessionId` identifies the immediate parent in an agent tree. It survives resume and is read back with `getParentSessionId()`.

```json
{"type":"session","version":3,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project","parentSession":"/path/to/parent/session.jsonl","parentSessionId":"parent-uuid","rlmDepth":1}
```

### SessionMessageEntry

A message in the conversation. The `message` field contains an `AgentMessage`.
Expand Down
11 changes: 5 additions & 6 deletions packages/coding-agent/src/core/agent-session-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,12 +314,11 @@ export class AgentSessionRuntime implements SubagentRuntimeHost {

async createRlmSubagentRuntime(options: CreateRlmSubagentRuntimeOptions): Promise<RlmSubagentRuntime> {
const sessionManager = SessionManager.create(options.parentSession.sessionManager.getCwd(), options.sessionDir);
if (options.parentSession.sessionFile) {
sessionManager.newSession({
parentSession: options.parentSession.sessionFile,
rlmDepth: options.rlmDepth,
});
}
sessionManager.newSession({
parentSession: options.parentSession.sessionFile,
parentSessionId: options.parentSession.sessionId,
rlmDepth: options.rlmDepth,
});
const runtime = await this.scopedBuild(() =>
createAgentSessionRuntime(this.createRuntime, {
cwd: sessionManager.getCwd(),
Expand Down
14 changes: 8 additions & 6 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10052,12 +10052,14 @@ export class AgentSession {

private _createInlineRlmSubagentRuntime(options: CreateRlmSubagentRuntimeOptions): RlmSubagentRuntime {
const childSessionManager = SessionManager.create(this._cwd, options.sessionDir);
if (options.parentSession.sessionFile) {
childSessionManager.newSession({
parentSession: options.parentSession.sessionFile,
rlmDepth: options.rlmDepth,
});
}
// Recorded unconditionally, matching the daemon host: an unpersisted parent
// has no file to point at, but the child still has to name the agent that
// spawned it so extensions can report the tree edge.
childSessionManager.newSession({
parentSession: options.parentSession.sessionFile,
parentSessionId: options.parentSession.sessionId,
rlmDepth: options.rlmDepth,
});
childSessionManager.appendModelChange(options.model.provider, options.model.id);
childSessionManager.appendThinkingLevelChange(options.thinkingLevel);
childSessionManager.appendServiceTierChange(options.serviceTier);
Expand Down
5 changes: 4 additions & 1 deletion packages/coding-agent/src/core/extensions/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,13 +236,16 @@ const noOpUIContext: ExtensionUIContext = {
* Read-only session view whose id identifies work derived from a session
* rather than the session itself. Everything else still reports the owning
* session, so an extension keyed on `getSessionId()` gets a distinct provider
* identity without losing access to the real transcript.
* identity without losing access to the real transcript. Notably
* `getParentSessionId()` still reports the owner's parent: scoped work belongs
* to the same place in the agent tree as the session that derived it.
*/
function scopedSessionManagerView(base: SessionManager, scope: string): ReadonlySessionManager {
return {
getCwd: () => base.getCwd(),
getSessionDir: () => base.getSessionDir(),
getSessionId: () => `${base.getSessionId()}/${scope}`,
getParentSessionId: () => base.getParentSessionId(),
getSessionFile: () => base.getSessionFile(),
getLeafId: () => base.getLeafId(),
getLeafEntry: () => base.getLeafEntry(),
Expand Down
20 changes: 20 additions & 0 deletions packages/coding-agent/src/core/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,20 @@ export interface SessionHeader {
timestamp: string;
cwd: string;
parentSession?: string;
/**
* Session id of the agent that spawned this one, set only for RLM children.
* `parentSession` is a file pointer that also records fork and new-session
* lineage, so it cannot answer "which live agent owns this child".
*/
parentSessionId?: string;
rlmDepth?: number;
git?: GitContext;
}

export interface NewSessionOptions {
id?: string;
parentSession?: string;
parentSessionId?: string;
rlmDepth?: number;
}

Expand Down Expand Up @@ -272,6 +279,7 @@ export type ReadonlySessionManager = Pick<
| "getEntries"
| "getTree"
| "getSessionName"
| "getParentSessionId"
>;

function createSessionId(): string {
Expand Down Expand Up @@ -1216,6 +1224,7 @@ export class SessionManager {
timestamp,
cwd: this.cwd,
parentSession: options?.parentSession,
parentSessionId: options?.parentSessionId,
rlmDepth,
git,
};
Expand Down Expand Up @@ -1310,6 +1319,16 @@ export class SessionManager {
return this.sessionId;
}

/**
* Session id of the agent that spawned this session, or undefined for a root
* session. Names the immediate parent, not the root of a deeper RLM tree, so
* a consumer holding every session can walk the chain. Lives in the session
* header, so a resumed child still reports its parent.
*/
getParentSessionId(): string | undefined {
return this.getHeader()?.parentSessionId;
}

getSessionFile(): string | undefined {
return this.sessionFile;
}
Expand Down Expand Up @@ -1337,6 +1356,7 @@ export class SessionManager {
timestamp,
cwd: this.cwd,
parentSession: previousHeader?.parentSession,
parentSessionId: previousHeader?.parentSessionId,
rlmDepth: resolveSessionRlmDepth(previousHeader ?? {}, target.sessionFile),
git,
};
Expand Down
1 change: 1 addition & 0 deletions packages/coding-agent/src/modes/daemon/daemon-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2463,6 +2463,7 @@ export class AgentDaemon {
const sessionManager = SessionManager.create(options.parentSession.sessionManager.getCwd(), options.sessionDir);
sessionManager.newSession({
parentSession: options.parentSession.sessionFile,
parentSessionId: options.parentSession.sessionId,
rlmDepth: options.rlmDepth,
});
let stateRef: ActiveSessionState | undefined;
Expand Down
Loading
Loading