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 @@ -265,3 +265,19 @@ decisions:
revisit_when:
- Prime upstream exposes an equivalent generation-scoped, post-attach public proof that is false before attach and after invalidation.
- Pylon and Comet can remove the fork SDK token/accessor without enabling optional behavior from a server offer, version, or method presence.

child-scoped-provider-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/23
upstream_refs:
- https://github.com/PrimeIntellect-ai/prime-agent/tree/a903d4b6768f484bd6d459b7b0aa7dee38e461e2
fork_change: child-scoped-provider-identity-v1
upstream_support: Prime through a903d4b6768f constructs inline RLM children and side questions with the root session's onPayload/onResponse closures, and calls compaction, branch summarization, and refinement completeSimple with no payload hook. Every derived request therefore either claims the parent's provider session identity while sending a divergent history or arrives with no identity at all.
revisit_when:
- Prime routes subagent and derived-request provider hooks through the owning session's extension runner.
- An upstream extension contract exposes per-agent provider identity that supersedes the scoped session-id view.
8 changes: 8 additions & 0 deletions .pylon/upstream-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,11 @@ This ledger records Prime upstream evidence and the decision taken for each over
- Large-transcript handling prepares one immutable payload, bounds framing and drain waits, avoids quadratic private-buffer shifting, and preserves spill ownership and cleanup across cancellation, crash, and stale generations. Stock/current `v0.8.1` supervisor and worker directions retain their mixed-version fallback.
- The pre-ledger source candidate `8b504e3774875c241c5d0d3b4b588a09f4aa3f8e` passed `npm run check`, package build, 246 conflict-affected exact-head tests after rebase, 16 real supervisor-process tests with 8 fixture-gated skips, stock/current compatibility in both directions, a 36 MiB exact-package transfer, a 10,000-message preparation probe, a 131,000-fragment framing probe, and two independent adversarial reviews. The ledger correction changes the exact head and therefore requires renewed targeted checks and hosted CI before merge.
- Revisit when Prime upstream supplies the same capability-gated fresh-generation identity, attachment-local retry containment, mixed-version behavior, and bounded preparation/framing guarantees without weakening Pylon's correlated lifecycle or cleanup contracts.

## 2026-08-30 — child-scoped provider identity for subagents and derived requests

- Upstream baseline: `PrimeIntellect-ai/prime-agent@a903d4b6768f484bd6d459b7b0aa7dee38e461e2`; this fix is client-local and does not advance `reviewed_upstream_commit`.
- Reviewed current upstream `agent-session.ts`, `side-question.ts`, `sdk.ts`, `compaction/`, and `refinement/`, plus upstream issues and pull requests for provider identity, `metadata.user_id`, and `before_provider_request`. Upstream has the same defect and no equivalent work; only issue #832 touches provider hooks, and it adds unrelated header/settled hooks.
- `child-scoped-provider-identity`: **retain**. Inline RLM children now run their payload, response, and context hooks through their own extension runner, converging with the daemon path that already gets a per-child runner from `createAgentSession`. Side questions and the compaction, branch-summary, refine, and auto-refine-review passes run through a scoped view of the owning session whose `getSessionId()` returns `<sessionId>/<scope>`; every other accessor still reports the owning session. The extension contract stays additive: existing `before_provider_request` handlers keep working and simply observe one identity per agent instead of the parent's for all of them.
- Out of scope by design: `SessionManager.sessionId` still changes on fork and branch. A genuinely divergent history deserves a new provider key, so persisting identity across forks is a separate decision.
- Validation: `npm run check` clean. `test/suite/regressions/23-child-provider-identity.test.ts` passes 3/3 and fails on the pre-fix inline-child wiring. Adjacent suites pass: side questions, fast-mode children, compaction (suite, extensions, summary reasoning), refinement, subagent runtime host, subagent model selection, subagent terminal messages, agent-session runtime, recursion, context tree, concurrent sessions, daemon agent connection, and the `packages/ai` faux provider — 620 passes with 8 skips across 18 files.
1 change: 1 addition & 0 deletions packages/ai/.changes/23-faux-provider-payload-hook.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Added `onPayload` support and recorded request payloads (`getSentPayloads`, `clearSentPayloads`) to the faux provider so payload-rewriting hosts and extensions can be tested ([#23](https://github.com/pylon-code/prime-agent/issues/23)).
33 changes: 33 additions & 0 deletions packages/ai/src/providers/faux.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,20 @@ export interface RegisterFauxProviderOptions {
};
}

/**
* Stand-in for a provider's serialized request body. Real providers build an
* API-shaped payload and hand it to `onPayload` before sending; the faux
* provider builds this instead so hosts and extensions that stamp identity
* onto the payload are exercised and observable.
*/
export interface FauxRequestPayload {
model: string;
systemPrompt?: string;
messages: Message[];
sessionId?: string;
metadata?: Record<string, unknown>;
}

export interface FauxProviderRegistration {
api: string;
models: [Model<string>, ...Model<string>[]];
Expand All @@ -122,6 +136,9 @@ export interface FauxProviderRegistration {
setResponses: (responses: FauxResponseStep[]) => void;
appendResponses: (responses: FauxResponseStep[]) => void;
getPendingResponseCount: () => number;
/** Payloads as they stood after `onPayload`, in request order. */
getSentPayloads: () => unknown[];
clearSentPayloads: () => void;
unregister: () => void;
}

Expand Down Expand Up @@ -401,6 +418,7 @@ export function registerFauxProvider(options: RegisterFauxProviderOptions = {}):
const tokensPerSecond = options.tokensPerSecond;
const state = { callCount: 0 };
const promptCache = new Map<string, string>();
const sentPayloads: unknown[] = [];

const modelDefinitions = options.models?.length
? options.models
Expand Down Expand Up @@ -435,6 +453,15 @@ export function registerFauxProvider(options: RegisterFauxProviderOptions = {}):

queueMicrotask(async () => {
try {
const payload: FauxRequestPayload = {
model: requestModel.id,
systemPrompt: context.systemPrompt,
messages: context.messages,
sessionId: streamOptions?.sessionId,
metadata: streamOptions?.metadata,
};
const replacedPayload = await streamOptions?.onPayload?.(payload, requestModel);
sentPayloads.push(replacedPayload ?? payload);
await streamOptions?.onResponse?.({ status: 200, headers: {} }, requestModel);
if (!step) {
let message = createErrorMessage(
Expand Down Expand Up @@ -492,6 +519,12 @@ export function registerFauxProvider(options: RegisterFauxProviderOptions = {}):
getPendingResponseCount() {
return pendingResponses.length;
},
getSentPayloads() {
return [...sentPayloads];
},
clearSentPayloads() {
sentPayloads.length = 0;
},
unregister() {
unregisterApiProviders(sourceId);
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Fixed inline subagents, side questions, compaction, branch summarization, and refinement sending provider requests under the parent session's identity or none at all ([#23](https://github.com/pylon-code/prime-agent/issues/23)).
7 changes: 7 additions & 0 deletions packages/coding-agent/docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -649,6 +649,13 @@ pi.on("before_provider_request", (event, ctx) => {

This is mainly useful for debugging provider serialization and cache behavior.

The hook fires for every provider request the session is responsible for, and `ctx.sessionManager.getSessionId()` identifies the conversation the request belongs to rather than the session you started from:

- A subagent's requests report the subagent's own session id, whether it runs inline or in the daemon. Handlers that key provider state on the session id therefore see one identity per agent instead of the parent's for all of them.
- Requests that carry a conversation the session never sent report a derived id of the form `<sessionId>/<scope>`: `side:<id>` for a side question, and `compaction`, `branch-summary`, `refine`, or `auto-refine-review` for the summarization passes. Everything else on `ctx.sessionManager` still describes the owning session.

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

#### 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
43 changes: 39 additions & 4 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ import { normalizeHeartbeatDeliveryMode } from "./cron-jobs.js";
import { DEFAULT_THINKING_LEVEL } from "./defaults.js";
import { exportSessionToHtml, type ToolHtmlRenderer } from "./export-html/index.js";
import { createToolHtmlRenderer } from "./export-html/tool-renderer.js";
import { createExtensionProviderHooks, type ExtensionProviderHooks } from "./extension-provider-hooks.js";
import {
type ContextUsage,
type ExtensionCommandContextActions,
Expand Down Expand Up @@ -1354,6 +1355,19 @@ export class AgentSession {
this._mcpManager?.refresh();
}

/**
* Provider hooks for requests made on this session's behalf that send a
* conversation this session never sent — side questions and the
* summarization/refinement passes. `scope` derives a child identity from the
* session id so those requests reach the provider identified, without
* claiming the session's own provider session key for a divergent history.
*
* The runner is resolved per request, so `/reload` is picked up.
*/
createScopedProviderHooks(scope: string): ExtensionProviderHooks {
return createExtensionProviderHooks(() => this._extensionRunner, { sessionIdScope: scope });
}

/**
* Set the RLM heartbeat controller after construction. Used by
* print/headless mode to attach an in-process heartbeat scheduler
Expand Down Expand Up @@ -8015,7 +8029,16 @@ export class AgentSession {

const { summary, firstKeptEntryId, tokensBefore, details } =
extensionCompaction ??
(await compact(preparation, model, apiKey, headers, customInstructions, signal, this.thinkingLevel));
(await compact(
preparation,
model,
apiKey,
headers,
customInstructions,
signal,
this.thinkingLevel,
this.createScopedProviderHooks("compaction").onPayload,
));

if (signal.aborted) {
throw new Error("Compaction cancelled");
Expand Down Expand Up @@ -8512,6 +8535,7 @@ export class AgentSession {
headers,
signal,
this.thinkingLevel,
this.createScopedProviderHooks("auto-refine-review").onPayload,
);
}

Expand Down Expand Up @@ -8751,6 +8775,7 @@ export class AgentSession {
headers,
signal,
this.thinkingLevel,
this.createScopedProviderHooks("refine").onPayload,
);
if (this._disposed || signal.aborted) {
throw new Error("Refinement cancelled because the session was disposed.");
Expand Down Expand Up @@ -10002,6 +10027,14 @@ export class AgentSession {
childSessionManager.appendThinkingLevelChange(options.thinkingLevel);
childSessionManager.appendServiceTierChange(options.serviceTier);

// The child's extension hooks must run against the child's own runner, or
// every request it makes claims this session's provider identity and the
// parent and its children interleave on one provider session key. The
// runner only exists once the child AgentSession builds it below, which is
// why the hooks resolve it late — the same shape the SDK root uses.
const childExtensionRunnerRef: { current?: ExtensionRunner } = {};
const childProviderHooks = createExtensionProviderHooks(() => childExtensionRunnerRef.current);

const childAgent = new Agent({
initialState: {
systemPrompt: "",
Expand All @@ -10011,11 +10044,11 @@ export class AgentSession {
tools: [],
},
convertToLlm: this.agent.convertToLlm,
transformContext: this.agent.transformContext,
transformContext: childProviderHooks.transformContext,
streamFn: this.agent.streamFn,
getApiKey: this.agent.getApiKey,
onPayload: this.agent.onPayload,
onResponse: this.agent.onResponse,
onPayload: childProviderHooks.onPayload,
onResponse: childProviderHooks.onResponse,
steeringMode: this.settingsManager.getSteeringMode(),
followUpMode: this.settingsManager.getFollowUpMode(),
sessionId: childSessionManager.getSessionId(),
Expand All @@ -10035,6 +10068,7 @@ export class AgentSession {
resourceLoader: this._resourceLoader,
customTools: options.customTools,
modelRegistry: this._modelRegistry,
extensionRunnerRef: childExtensionRunnerRef,
initialActiveToolNames: options.activeToolNames,
allowedToolNames: options.allowedToolNames,
includeGoals: options.includeGoals,
Expand Down Expand Up @@ -12049,6 +12083,7 @@ export class AgentSession {
customInstructions,
replaceInstructions,
reserveTokens: branchSummarySettings.reserveTokens,
onPayload: this.createScopedProviderHooks("branch-summary").onPayload,
});
if (result.aborted) {
return { cancelled: true, aborted: true };
Expand Down
19 changes: 17 additions & 2 deletions packages/coding-agent/src/core/compaction/branch-summarization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import type { Model } from "@earendil-works/pi-ai";
import { completeSimple } from "@earendil-works/pi-ai";
import type { ProviderPayloadHook } from "../extension-provider-hooks.js";
import {
convertToLlm,
createBranchSummaryMessage,
Expand Down Expand Up @@ -72,6 +73,11 @@ export interface GenerateBranchSummaryOptions {
replaceInstructions?: boolean;
/** Tokens reserved for prompt + LLM response (default 16384) */
reserveTokens?: number;
/**
* Provider payload hook carrying the owning session's identity. Expected to
* be scoped: the summarization prompt is not the session's conversation.
*/
onPayload?: ProviderPayloadHook;
}
/**
* Collect entries that should be summarized when navigating from one position to another.
Expand Down Expand Up @@ -249,7 +255,16 @@ export async function generateBranchSummary(
entries: SessionEntry[],
options: GenerateBranchSummaryOptions,
): Promise<BranchSummaryResult> {
const { model, apiKey, headers, signal, customInstructions, replaceInstructions, reserveTokens = 16384 } = options;
const {
model,
apiKey,
headers,
signal,
customInstructions,
replaceInstructions,
reserveTokens = 16384,
onPayload,
} = options;
const contextWindow = model.contextWindow || 128000;
const tokenBudget = contextWindow - reserveTokens;

Expand Down Expand Up @@ -282,7 +297,7 @@ export async function generateBranchSummary(
const response = await completeSimple(
model,
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
{ apiKey, headers, signal, maxTokens: 2048 },
{ apiKey, headers, signal, onPayload, maxTokens: 2048 },
);
if (response.stopReason === "aborted") {
return { aborted: true };
Expand Down
19 changes: 15 additions & 4 deletions packages/coding-agent/src/core/compaction/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core";
import type { AssistantMessage, Model, Usage } from "@earendil-works/pi-ai";
import { completeSimple } from "@earendil-works/pi-ai";
import type { ProviderPayloadHook } from "../extension-provider-hooks.js";
import {
convertToLlm,
createBranchSummaryMessage,
Expand Down Expand Up @@ -504,6 +505,10 @@ export function buildSummarizationPrompt(customInstructions?: string, previousSu
/**
* Generate a summary of the conversation using the LLM.
* If previousSummary is provided, uses the update prompt to merge.
*
* `onPayload` carries the owning session's provider identity so the call is
* not anonymous at the provider; it is expected to be scoped, because the
* summarization prompt is not the session's own conversation.
*/
export async function generateSummary(
currentMessages: AgentMessage[],
Expand All @@ -515,6 +520,7 @@ export async function generateSummary(
customInstructions?: string,
previousSummary?: string,
thinkingLevel?: ThinkingLevel,
onPayload?: ProviderPayloadHook,
): Promise<string> {
const maxTokens = Math.floor(0.8 * reserveTokens);

Expand All @@ -538,8 +544,8 @@ export async function generateSummary(

const completionOptions =
model.reasoning && thinkingLevel && thinkingLevel !== "off"
? { maxTokens, signal, apiKey, headers, reasoning: thinkingLevel }
: { maxTokens, signal, apiKey, headers };
? { maxTokens, signal, apiKey, headers, onPayload, reasoning: thinkingLevel }
: { maxTokens, signal, apiKey, headers, onPayload };

const response = await completeSimple(
model,
Expand Down Expand Up @@ -678,6 +684,7 @@ export async function compact(
customInstructions?: string,
signal?: AbortSignal,
thinkingLevel?: ThinkingLevel,
onPayload?: ProviderPayloadHook,
): Promise<CompactionResult> {
const {
firstKeptEntryId,
Expand All @@ -704,6 +711,7 @@ export async function compact(
customInstructions,
previousSummary,
thinkingLevel,
onPayload,
)
: Promise.resolve("No prior history."),
generateTurnPrefixSummary(
Expand All @@ -714,6 +722,7 @@ export async function compact(
headers,
signal,
thinkingLevel,
onPayload,
),
]);
summary = `${historyResult}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult}`;
Expand All @@ -728,6 +737,7 @@ export async function compact(
customInstructions,
previousSummary,
thinkingLevel,
onPayload,
);
}
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
Expand Down Expand Up @@ -756,6 +766,7 @@ async function generateTurnPrefixSummary(
headers?: Record<string, string>,
signal?: AbortSignal,
thinkingLevel?: ThinkingLevel,
onPayload?: ProviderPayloadHook,
): Promise<string> {
const maxTokens = Math.floor(0.5 * reserveTokens); // Smaller budget for turn prefix
const llmMessages = convertToLlm(messages);
Expand All @@ -773,8 +784,8 @@ async function generateTurnPrefixSummary(
model,
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
model.reasoning && thinkingLevel && thinkingLevel !== "off"
? { maxTokens, signal, apiKey, headers, reasoning: thinkingLevel }
: { maxTokens, signal, apiKey, headers },
? { maxTokens, signal, apiKey, headers, onPayload, reasoning: thinkingLevel }
: { maxTokens, signal, apiKey, headers, onPayload },
);

if (response.stopReason === "error") {
Expand Down
Loading
Loading