Skip to content

Commit 1c9fd72

Browse files
authored
Merge pull request #38 from pylon-code/feat/parent-session-identity
feat(coding-agent): expose the spawning session's identity to child extension contexts
2 parents eb1c8d9 + ae0baa4 commit 1c9fd72

11 files changed

Lines changed: 306 additions & 13 deletions

File tree

.pylon/features.yaml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,3 +335,19 @@ decisions:
335335
revisit_when:
336336
- 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.
337337
- Pylon can drop the fork behavior without regressing measured cache-hit rates through a Claude-Max proxy or Meridian lineage continuation rates.
338+
339+
parent-session-identity:
340+
area: runtime-reliability
341+
state: shipped
342+
owner: pylon-prime-integration
343+
decision: retain
344+
pylon_refs:
345+
- https://github.com/pylon-code/prime-agent/issues/22
346+
- https://github.com/pylon-code/prime-agent/issues/34
347+
upstream_refs:
348+
- https://github.com/PrimeIntellect-ai/prime-agent/tree/c718bf3c30fd8da206ed551837cbb54f7ad15948
349+
fork_change: parent-session-identity-v1
350+
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.
351+
revisit_when:
352+
- Prime records a spawn-only parent session id in the session header or exposes an equivalent accessor on the extension-visible session manager.
353+
- The proxy-side parent-tree cancellation registry in rynfar/meridian#902 stops needing the parent edge inside the provider payload.

.pylon/upstream-review.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,3 +162,13 @@ This ledger records Prime upstream evidence and the decision taken for each over
162162
- 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`.
163163
- 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.
164164
- 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.
165+
166+
## 2026-08-31 — parent session identity for RLM child extension contexts
167+
168+
- Upstream baseline: `PrimeIntellect-ai/prime-agent@c718bf3c30fd8da206ed551837cbb54f7ad15948`; this addition is client-local and does not advance `reviewed_upstream_commit`.
169+
- 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.
170+
- `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.
171+
- 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.
172+
- 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`.
173+
- Additive only: no daemon command, event, or response shape changes. The parent edge rides inside the `metadata.user_id` envelope extensions already produce.
174+
- 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.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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)).

packages/coding-agent/docs/extensions.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,24 @@ The hook fires for every provider request the session is responsible for, and `c
656656

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

659+
`ctx.sessionManager.getParentSessionId()` names the agent that spawned the session, so a handler can stamp the whole tree edge and not just one node:
660+
661+
```typescript
662+
pi.on("before_provider_request", (event, ctx) => {
663+
return {
664+
...event.payload,
665+
metadata: {
666+
user_id: JSON.stringify({
667+
session_id: ctx.sessionManager.getSessionId(),
668+
parent_session_id: ctx.sessionManager.getParentSessionId(),
669+
}),
670+
},
671+
};
672+
});
673+
```
674+
675+
See [ctx.sessionManager](#ctxsessionmanager) for what the parent id does and does not cover.
676+
659677
#### after_provider_response
660678

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

941+
`getParentSessionId()` describes the agent tree:
942+
943+
- A root session returns `undefined`. A subagent returns the session id of the agent that spawned it, inline or in the daemon.
944+
- It names the **immediate** parent. A grandchild reports its own parent, not the root, so a handler holding several sessions can walk the chain.
945+
- It is recorded in the session header, so a resumed or reattached subagent still reports its parent.
946+
- Scoped requests (`<sessionId>/side:<id>`, `<sessionId>/compaction`, ...) report the parent of the session that owns them. The scope changes `getSessionId()` only.
947+
- 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.
948+
921949
### ctx.modelRegistry / ctx.model
922950

923951
Access to models and API keys.

packages/coding-agent/docs/session-format.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,12 @@ For sessions with a parent (created via `/fork`, `/clone`, or `newSession({ pare
196196
{"type":"session","version":3,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project","parentSession":"/path/to/original/session.jsonl"}
197197
```
198198

199+
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()`.
200+
201+
```json
202+
{"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}
203+
```
204+
199205
### SessionMessageEntry
200206

201207
A message in the conversation. The `message` field contains an `AgentMessage`.

packages/coding-agent/src/core/agent-session-runtime.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -314,12 +314,11 @@ export class AgentSessionRuntime implements SubagentRuntimeHost {
314314

315315
async createRlmSubagentRuntime(options: CreateRlmSubagentRuntimeOptions): Promise<RlmSubagentRuntime> {
316316
const sessionManager = SessionManager.create(options.parentSession.sessionManager.getCwd(), options.sessionDir);
317-
if (options.parentSession.sessionFile) {
318-
sessionManager.newSession({
319-
parentSession: options.parentSession.sessionFile,
320-
rlmDepth: options.rlmDepth,
321-
});
322-
}
317+
sessionManager.newSession({
318+
parentSession: options.parentSession.sessionFile,
319+
parentSessionId: options.parentSession.sessionId,
320+
rlmDepth: options.rlmDepth,
321+
});
323322
const runtime = await this.scopedBuild(() =>
324323
createAgentSessionRuntime(this.createRuntime, {
325324
cwd: sessionManager.getCwd(),

packages/coding-agent/src/core/agent-session.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10052,12 +10052,14 @@ export class AgentSession {
1005210052

1005310053
private _createInlineRlmSubagentRuntime(options: CreateRlmSubagentRuntimeOptions): RlmSubagentRuntime {
1005410054
const childSessionManager = SessionManager.create(this._cwd, options.sessionDir);
10055-
if (options.parentSession.sessionFile) {
10056-
childSessionManager.newSession({
10057-
parentSession: options.parentSession.sessionFile,
10058-
rlmDepth: options.rlmDepth,
10059-
});
10060-
}
10055+
// Recorded unconditionally, matching the daemon host: an unpersisted parent
10056+
// has no file to point at, but the child still has to name the agent that
10057+
// spawned it so extensions can report the tree edge.
10058+
childSessionManager.newSession({
10059+
parentSession: options.parentSession.sessionFile,
10060+
parentSessionId: options.parentSession.sessionId,
10061+
rlmDepth: options.rlmDepth,
10062+
});
1006110063
childSessionManager.appendModelChange(options.model.provider, options.model.id);
1006210064
childSessionManager.appendThinkingLevelChange(options.thinkingLevel);
1006310065
childSessionManager.appendServiceTierChange(options.serviceTier);

packages/coding-agent/src/core/extensions/runner.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,13 +236,16 @@ const noOpUIContext: ExtensionUIContext = {
236236
* Read-only session view whose id identifies work derived from a session
237237
* rather than the session itself. Everything else still reports the owning
238238
* session, so an extension keyed on `getSessionId()` gets a distinct provider
239-
* identity without losing access to the real transcript.
239+
* identity without losing access to the real transcript. Notably
240+
* `getParentSessionId()` still reports the owner's parent: scoped work belongs
241+
* to the same place in the agent tree as the session that derived it.
240242
*/
241243
function scopedSessionManagerView(base: SessionManager, scope: string): ReadonlySessionManager {
242244
return {
243245
getCwd: () => base.getCwd(),
244246
getSessionDir: () => base.getSessionDir(),
245247
getSessionId: () => `${base.getSessionId()}/${scope}`,
248+
getParentSessionId: () => base.getParentSessionId(),
246249
getSessionFile: () => base.getSessionFile(),
247250
getLeafId: () => base.getLeafId(),
248251
getLeafEntry: () => base.getLeafEntry(),

packages/coding-agent/src/core/session-manager.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,13 +79,20 @@ export interface SessionHeader {
7979
timestamp: string;
8080
cwd: string;
8181
parentSession?: string;
82+
/**
83+
* Session id of the agent that spawned this one, set only for RLM children.
84+
* `parentSession` is a file pointer that also records fork and new-session
85+
* lineage, so it cannot answer "which live agent owns this child".
86+
*/
87+
parentSessionId?: string;
8288
rlmDepth?: number;
8389
git?: GitContext;
8490
}
8591

8692
export interface NewSessionOptions {
8793
id?: string;
8894
parentSession?: string;
95+
parentSessionId?: string;
8996
rlmDepth?: number;
9097
}
9198

@@ -272,6 +279,7 @@ export type ReadonlySessionManager = Pick<
272279
| "getEntries"
273280
| "getTree"
274281
| "getSessionName"
282+
| "getParentSessionId"
275283
>;
276284

277285
function createSessionId(): string {
@@ -1216,6 +1224,7 @@ export class SessionManager {
12161224
timestamp,
12171225
cwd: this.cwd,
12181226
parentSession: options?.parentSession,
1227+
parentSessionId: options?.parentSessionId,
12191228
rlmDepth,
12201229
git,
12211230
};
@@ -1310,6 +1319,16 @@ export class SessionManager {
13101319
return this.sessionId;
13111320
}
13121321

1322+
/**
1323+
* Session id of the agent that spawned this session, or undefined for a root
1324+
* session. Names the immediate parent, not the root of a deeper RLM tree, so
1325+
* a consumer holding every session can walk the chain. Lives in the session
1326+
* header, so a resumed child still reports its parent.
1327+
*/
1328+
getParentSessionId(): string | undefined {
1329+
return this.getHeader()?.parentSessionId;
1330+
}
1331+
13131332
getSessionFile(): string | undefined {
13141333
return this.sessionFile;
13151334
}
@@ -1337,6 +1356,7 @@ export class SessionManager {
13371356
timestamp,
13381357
cwd: this.cwd,
13391358
parentSession: previousHeader?.parentSession,
1359+
parentSessionId: previousHeader?.parentSessionId,
13401360
rlmDepth: resolveSessionRlmDepth(previousHeader ?? {}, target.sessionFile),
13411361
git,
13421362
};

packages/coding-agent/src/modes/daemon/daemon-mode.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2463,6 +2463,7 @@ export class AgentDaemon {
24632463
const sessionManager = SessionManager.create(options.parentSession.sessionManager.getCwd(), options.sessionDir);
24642464
sessionManager.newSession({
24652465
parentSession: options.parentSession.sessionFile,
2466+
parentSessionId: options.parentSession.sessionId,
24662467
rlmDepth: options.rlmDepth,
24672468
});
24682469
let stateRef: ActiveSessionState | undefined;

0 commit comments

Comments
 (0)