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
8 changes: 8 additions & 0 deletions changelog.d/request-only-rows-stay.breaking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
- **Code that sets or reads `InferenceRequest.ephemeralSystemPrompt`:** the
field is removed. It carried a system-position prompt for one turn; the
silent heartbeat was its only producer, and its instruction now rides the
tick's request-only separator. There is no replacement: a system prompt
that changes for one turn costs that request its prompt cache from the
system on, and changes what every retained signed-thinking block is bound
to. Nothing in this package or in connectome-host sets or reads it.
Unchanged: `suppressProse` and `silentHeartbeat` on `InferenceRequest`.
40 changes: 40 additions & 0 deletions changelog.d/request-only-rows-stay.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
- A silent heartbeat tick's instruction now rides the tick's request-only
separator instead of a system-position injection, so a tick's request
carries the agent's own system prompt, like every other turn. A system
prompt that changed for one turn cost the heartbeat's request its prompt
cache from the system on, and on a provider that binds signed thinking to
the prefix it was minted under (context-manager #155) it failed every
retained thinking block in that request, and the tick reply's own thinking
at the next ordinary turn. `SILENT_HEARTBEAT_SEPARATOR` is now that
instruction (199 characters where it was 16, roughly 50 tokens at each
tick whose rows survive in the window). Each tick's rows now record the
separator their request rendered (`SilentHeartbeatStamp.separator`), and
later compiles render that text, so ticks stored before upgrading keep
their `[heartbeat tick]` marker byte for byte: no stored reply's prefix
changes. (`InferenceRequest.ephemeralSystemPrompt` is removed; see the
breaking entry.)
- A tick whose restart compiles none of the tick's rows (a strategy folded
them all) opens again: the restart's request ends on the separator, where
later compiles render it before the restart's reply. It used to end on
`[Continue]`, and later compiles put the separator before a reply whose
request never held it.
- When a compile ends on the agent's own message and the request gets a
trailing `[Continue]` turn, the reply's rows (its assistant responses and
tool results) now record that prompt (`metadata.promptedBy`, a
`{ turn, text }` pair), and every later request renders `[Continue]`
again just before the first of its assistant rows that survives, the way a
silent tick's separator is rendered. A later reply follows the same prompt
when what it is sent ends on the tool results of a reply that did: a next
round of the stream, a budget restart, an error retry, or a new process
reading the stored rows. A reply sent anything else after those results,
such as a message that arrived at the tool boundary or during a provider
cooldown, follows that instead and records no prompt. Without the turn,
later requests merged the reply into the previous assistant message (one
message carrying signed thinking from two responses, a 400 when it is the
latest), and dropped a turn the reply's request held, which a provider
that binds thinking refuses. A reply with neither a tool call nor signed or
redacted thinking can't be found in a compiled window and merges as
before. `StartStreamResult.continuePrompt` carries the prompt a stream's
request ends on (new, optional); the agent sets it. The direct
`runInference` paths compile without these request-only turns, as they
did without the tick separator, so their replies render none.
3 changes: 3 additions & 0 deletions changelog.d/request-only-rows-stay.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
- A silent heartbeat tick is now told apart by its server and its event id
together, so two servers' ticks whose events share an id each get their
own separator, before their own first row.
108 changes: 75 additions & 33 deletions src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,19 @@ import type { CacheWireReceipt, KvUnifiedRequestHooks } from './kv-unified-wire.
import { ToolResultGuard } from './tool-result-guard.js';
import { assertBooleanSetting, holdsSettings } from './config-checks.js';
import {
indexSilentTickRows,
separateSilentHeartbeatTicks,
CONTINUE_ROW,
continueTurn,
indexRequestOnlyRows,
renderRequestOnlyRows,
silentHeartbeatSeparatorTurn,
tickKey,
toolLoopPrompt,
type RequestOnlyPrompt,
type RequestOnlyRowIndex,
type SilentHeartbeatTick,
type SilentTickIndex,
type StoredRowLike,
} from './silent-heartbeat.js';
import { randomUUID } from 'node:crypto';
import {
nonErrorResultText,
truncateForHistory,
Expand All @@ -29,6 +35,13 @@ export interface ActivationRequestOptions {
export interface StartStreamResult {
stream: YieldingStream;
request: NormalizedRequest;
/** The request-only `[Continue]` prompt this request's reply follows, if
* any, read from the request: a new one when it ends on `[Continue]`;
* else, when it ends on tool results, the prompt of the reply that called
* those tools (`toolLoopPrompt`); else none. The framework stamps the
* reply's rows with it (`metadata.promptedBy`), for later compiles to
* render it before them. */
continuePrompt?: RequestOnlyPrompt;
takeKvSubmission?: () => { submissionId: string; wireReceipt: CacheWireReceipt } | undefined;
drainKvSubmissionIds?: () => string[];
}
Expand Down Expand Up @@ -1221,13 +1234,13 @@ export class Agent {
* may show a withheld stub before its place is added; the activation waits
* for them around its call.
*/
async buildActivationRequest(
private async assembleActivationRequest(
availableTools: ToolDefinition[],
injections?: ContextInjection[],
budget?: TokenBudget,
compressionTools: ToolDefinition[] = availableTools,
options: ActivationRequestOptions = {},
): Promise<NormalizedRequest> {
): Promise<{ request: NormalizedRequest; continuePrompt?: RequestOnlyPrompt }> {
// Compression may revisit hidden tools in recorded history. Keep its full
// definition set separate from the resident's advertised tools: the
// autobiographical strategy must declare the same tools on its
Expand Down Expand Up @@ -1286,44 +1299,59 @@ export class Agent {
}))
.filter((m) => m.content.length > 0);

// Silent heartbeat ticks store no prompt row. Render a request-only
// separator before each tick's first surviving row so the wire formatter
// cannot merge the tick into the previous assistant message (see
// silent-heartbeat.ts for what can and cannot be matched). This agent's
// own first request for a tick ends on that same turn, so the prefix it
// caches is the one every later compile renders. "First" is per agent: a
// broadcast tick shares its eventId with every resident.
const ticks = this.indexSilentHeartbeatTicks();
messages = separateSilentHeartbeatTicks(messages, ticks.rows, this.name);
// Silent heartbeat ticks and replies to a [Continue] turn store no
// prompt row. Render each request-only turn before its response's first
// surviving row, so the wire formatter cannot merge the response into the
// previous assistant message and every later request keeps the prefix
// the response was minted under (see silent-heartbeat.ts for what can
// and cannot be matched). The request that minted the response ended on
// that same turn, so the prefix it caches is the one every later compile
// renders. "First" is per agent: a broadcast tick shares its eventId with
// every resident.
const index = this.indexRequestOnlyRows();
const rendered = renderRequestOnlyRows(messages, index, this.name);
messages = rendered.messages;
// A tick opens wherever its separator isn't already rendered: on its first
// request, and on a restart whose compile left none of the tick's rows,
// so the restart's reply follows the separator, where every later compile
// renders it.
const openingTick = options.silentHeartbeat !== undefined
&& !ticks.started.has(options.silentHeartbeat.eventId);

&& !rendered.separatedTicks.has(tickKey(options.silentHeartbeat));

// The [Continue] prompt this request's reply follows, read from the
// request itself: a new one when it ends on [Continue]; else, when it
// ends on tool results, the prompt of the reply that called those tools,
// whose tool loop this reply continues; else none. A request that ends
// on anything else (a message that arrived during a cooldown, say) gets
// a reply that follows that message and carries no prompt.
let continuePrompt: RequestOnlyPrompt | undefined;
if (openingTick) {
messages = [...messages, silentHeartbeatSeparatorTurn()];
Comment thread
greptile-apps[bot] marked this conversation as resolved.
} else if (messages.length > 0 && messages[messages.length - 1]!.participant === this.name) {
// Safety: ensure messages don't end with an assistant message.
// Some models reject trailing assistant messages ("prefill not supported"),
// and after context compression a stale assistant turn can end up last.
messages = [...messages, {
participant: 'user',
content: [{ type: 'text', text: '[Continue]' }],
}];
messages = [...messages, continueTurn()];
continuePrompt = { turn: randomUUID(), text: CONTINUE_ROW };
} else {
continuePrompt = toolLoopPrompt(messages.at(-1), index);
}

return {
const request: NormalizedRequest = {
messages: this.toolResultGuard.prepareRequest(messages),
system: this.buildSystemPrompt(systemInjections),
tools: availableTools.length > 0 ? availableTools : undefined,
...this.requestSettings(),
...(kvUnified ? { cacheMarkers: 'cm-owned' as const } : {}),
};
return { request, ...(continuePrompt ? { continuePrompt } : {}) };
}

/**
* The request fields that come from this agent's own settings: its model
* config, prompt caching and cache TTL, prefill message, provider
* parameters and participant name. Every request the agent sends takes
* them from here, a framework turn's (buildActivationRequest) and the
* them from here, a framework turn's (assembleActivationRequest) and the
* direct API's (runInferenceWithInjections), so a setting reaches both or
* neither.
*/
Expand All @@ -1344,22 +1372,35 @@ export class Agent {
};
}

/** Stored silent-tick rows, memoized on the store's cached message array
* (ContextManager returns the same array until the store changes). */
private silentTickIndex: { source: readonly unknown[]; index: SilentTickIndex } | null = null;
/** The request `assembleActivationRequest` builds (see there), for preview
* and debug tooling: what this agent's next activation would send. */
async buildActivationRequest(
availableTools: ToolDefinition[],
injections?: ContextInjection[],
budget?: TokenBudget,
compressionTools: ToolDefinition[] = availableTools,
options: ActivationRequestOptions = {},
): Promise<NormalizedRequest> {
return (await this.assembleActivationRequest(availableTools, injections, budget, compressionTools, options)).request;
}

/** Stored rows that follow a request-only turn, memoized on the store's
* cached message array (ContextManager returns the same array until the
* store changes). */
private requestOnlyIndex: { source: readonly unknown[]; index: RequestOnlyRowIndex } | null = null;

private indexSilentHeartbeatTicks(): SilentTickIndex {
const empty: SilentTickIndex = { rows: new Map(), started: new Set() };
private indexRequestOnlyRows(): RequestOnlyRowIndex {
const empty: RequestOnlyRowIndex = { ticks: new Map(), prompts: new Map() };
const cm = this.contextManager as Partial<ContextManager>;
if (typeof cm.getAllMessages !== 'function') return empty;
try {
const stored = cm.getAllMessages() as unknown as readonly StoredRowLike[];
if (this.silentTickIndex?.source === stored) return this.silentTickIndex.index;
const index = indexSilentTickRows(stored, this.name);
this.silentTickIndex = { source: stored, index };
if (this.requestOnlyIndex?.source === stored) return this.requestOnlyIndex.index;
const index = indexRequestOnlyRows(stored, this.name);
this.requestOnlyIndex = { source: stored, index };
return index;
} catch (error) {
console.error(`[silent-heartbeat] ${this.name}: could not index tick rows; separators omitted:`, error);
console.error(`[request-only-rows] ${this.name}: could not index stored rows; request-only turns omitted:`, error);
return empty;
}
}
Expand Down Expand Up @@ -1423,8 +1464,8 @@ export class Agent {
// Compiled only once each withheld stub's annotation has finished,
// including one that began during the compile, and after a stub edit that
// failed has been retried (see afterAnnotations).
const request = await this.toolResultGuard.afterAnnotations(
() => this.buildActivationRequest(availableTools, injections, budget, compressionTools, options));
const { request, continuePrompt } = await this.toolResultGuard.afterAnnotations(
() => this.assembleActivationRequest(availableTools, injections, budget, compressionTools, options));
request.messages = this.toolResultGuard.prepareRequest(request.messages, true);

const receiptAware = (this.contextManager as unknown as { getStrategy?: () => unknown })
Expand Down Expand Up @@ -1473,6 +1514,7 @@ export class Agent {
return {
stream,
request,
...(continuePrompt ? { continuePrompt } : {}),
...(kvEnabled
? {
takeKvSubmission: () => kvQueue.shift(),
Expand Down
Loading
Loading