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
1 change: 1 addition & 0 deletions SCIENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Everything else is upstream's code and behavior.
| Browser relay | The relay listens on port 9324 (OMP uses 9224), so each product runs its own. |
| Next message | `predict_next_message` gives an RPC host the prediction of the user's next message that OMP's terminal composer shows after a turn: OMP's prompt and parsing, on the session's model and context, without changing its history. It runs outside the command queue; a newer request replaces one in flight, and a turn or compaction that starts cancels it. |
| Usage-limit reset | A `prompt_result` for a turn stopped by a usage limit carries `error.resetAt`, the time the provider said the limit resets (epoch ms): from its retry hint, the account's usage report, or an earlier stated block, whichever is latest. It is left out when the provider gave no time; the 30-minute guess for a hintless error is never reported. A host can resume the turn at that time. |
| Prompt cache | An assistant reply's `message_end` carries `promptCache` (`tier`, `touchedAt`, `ttlMs`): the prompt-cache entry its response confirmed, while the next request would still read it. It is left out for a cache that never expires and for an entry the agent only inferred. A host can compact an idle conversation just before a one-hour entry expires, so the summary reads a warm cache. The agent's own idle compaction stays a terminal feature and never runs over RPC. |
| Failure kinds | A `prompt_result` for a failed turn carries `error.kinds`, the error classifier's labels for that failure (`auth-failed`, `context-overflow`, `transient`, `usage-limit`, ...), so a host can report why turns fail. It is left out when the classifier recognized no kind; the labels carry no provider text. |
| macOS Accessibility prompt | The agent sends no Accessibility message while macOS doesn't trust it, so a computer call that refuses for lack of a permission never makes macOS ask. Only the host asks for permissions. |
| Live voice delegation | `live_connect` with `hostDelegation` sends each request the voice model delegates to the host as a `live_delegation` frame instead of starting a turn, so a host that owns its turns (Scient Desktop) sends it as the user's message. The live session still reads that turn's answer. A host that runs the turn elsewhere, such as in the conversation's own agent while the call runs in a separate one, reports its progress and answer with `live_report`. Without the flag nothing changes. |
Expand Down
7 changes: 6 additions & 1 deletion packages/coding-agent/src/modes/rpc/rpc-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ import { rpcFormDialog } from "../../scient/rpc-form";
// scient(SA-NEXT-MESSAGE): begin - A host shows the agent's prediction of the user's next message.
import { RpcNextMessagePredictor } from "../../scient/next-message";
// scient(SA-NEXT-MESSAGE): end
// scient(SA-PROMPT-CACHE): begin - A host compacts an idle conversation before its prompt cache expires.
import { withPromptCache } from "../../scient/prompt-cache";
// scient(SA-PROMPT-CACHE): end
import { pageRpcMessages, RPC_MESSAGES_PAGE_BUSY_ERROR, RpcMessagesPageError } from "./rpc-messages";
import { RpcBtwController } from "./rpc-btw";
import { RpcGoalController } from "./rpc-goal";
Expand Down Expand Up @@ -1840,7 +1843,9 @@ export async function runRpcMode(session: AgentSession, options: RpcModeOptions

// Output all agent events as JSON; prompt results follow the frame that settled them.
session.subscribe(event => {
sessionEvents.forward(event);
// scient(SA-PROMPT-CACHE): begin - A host compacts an idle conversation before its prompt cache expires.
sessionEvents.forward(withPromptCache(event, session));
// scient(SA-PROMPT-CACHE): end
// Before the prompt-result and settle reports: a goal continuation decided at this
// agent_end is scheduled (and reported as pending) before either reads settlement.
goalController.observe(event);
Expand Down
17 changes: 16 additions & 1 deletion packages/coding-agent/src/modes/rpc/wire/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,22 @@ export const eventDefs = {
assistantMessageEvent: "AssistantMessageEvent",
"messageId?": MESSAGE_ID,
},
MessageEndEvent: { type: "'message_end'", message: "AgentMessage", "messageId?": MESSAGE_ID },
// scient(SA-PROMPT-CACHE): begin - A host compacts an idle conversation before its prompt cache expires.
MessageEndEvent: {
type: "'message_end'",
message: "AgentMessage",
"messageId?": MESSAGE_ID,
"promptCache?": doc(
"PromptCacheEntry",
"On an assistant reply: the prompt-cache entry its response confirmed, while the next request would still read it. Absent for a cache that never expires.",
),
},
PromptCacheEntry: {
tier: doc("'short' | 'long'", "Retention tier the provider wrote the entry under: minutes or about an hour."),
touchedAt: doc("number.integer", "Epoch ms when a request last wrote or read the entry (its send time)."),
ttlMs: doc("number.integer", "How long the provider keeps the entry after `touchedAt`."),
},
// scient(SA-PROMPT-CACHE): end
ToolExecutionStartEvent: {
type: "'tool_execution_start'",
toolCallId: "string",
Expand Down
11 changes: 11 additions & 0 deletions packages/coding-agent/src/modes/rpc/wire/rpc-wire.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -870,6 +870,17 @@ export interface MessageEndEvent {
message: AgentMessage;
/** Shared by the start, updates, and end of one message; unique per process. */
messageId?: string;
/** On an assistant reply: the prompt-cache entry its response confirmed, while the next request would still read it. Absent for a cache that never expires. */
promptCache?: PromptCacheEntry;
}

export interface PromptCacheEntry {
/** Retention tier the provider wrote the entry under: minutes or about an hour. */
tier: "short" | "long";
/** Epoch ms when a request last wrote or read the entry (its send time). */
touchedAt: number;
/** How long the provider keeps the entry after `touchedAt`. */
ttlMs: number;
}

export interface ToolExecutionStartEvent {
Expand Down
30 changes: 30 additions & 0 deletions packages/coding-agent/src/modes/rpc/wire/rpc-wire.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -3905,13 +3905,43 @@
"messageId": {
"type": "string",
"description": "Shared by the start, updates, and end of one message; unique per process."
},
"promptCache": {
"$ref": "#/$defs/PromptCacheEntry",
"description": "On an assistant reply: the prompt-cache entry its response confirmed, while the next request would still read it. Absent for a cache that never expires."
}
},
"required": [
"type",
"message"
]
},
"PromptCacheEntry": {
"type": "object",
"properties": {
"tier": {
"enum": [
"short",
"long"
],
"type": "string",
"description": "Retention tier the provider wrote the entry under: minutes or about an hour."
},
"touchedAt": {
"type": "integer",
"description": "Epoch ms when a request last wrote or read the entry (its send time)."
},
"ttlMs": {
"type": "integer",
"description": "How long the provider keeps the entry after `touchedAt`."
}
},
"required": [
"tier",
"touchedAt",
"ttlMs"
]
},
"ToolExecutionStartEvent": {
"type": "object",
"properties": {
Expand Down
44 changes: 44 additions & 0 deletions packages/coding-agent/src/scient/prompt-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* The prompt-cache entry a reply confirmed, for an RPC host that compacts an idle conversation just
* before the entry expires (Scient Desktop's "Compact idle threads"). The session already tracks
* the entry for the status line and the cache warmer; this file puts it on the reply's `message_end`.
*
* Only an entry a response this process saw confirmed is reported, while the next request would
* still read it. An inferred entry, a changed conversation and a cache that never expires report
* nothing. The agent's own idle compaction stays a terminal feature, so the two never race.
*/
import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
import type { PromptCacheTier } from "../session/cache-warmer";

export interface RpcPromptCacheEntry {
/** Retention tier the provider wrote the entry under: `short` (minutes) or `long` (about an hour). */
tier: PromptCacheTier;
/** Epoch ms when a request last wrote or read the entry: the send time, not the reply's end. */
touchedAt: number;
/** How long the provider keeps the entry after `touchedAt`. */
ttlMs: number;
}

export type PromptCacheSession = Pick<AgentSession, "getPromptCacheStatus">;

/** The confirmed, still-readable entry at `nowMs`; undefined when there is none or it never expires. */
export function confirmedPromptCache(session: PromptCacheSession, nowMs = Date.now()): RpcPromptCacheEntry | undefined {
// The token count only describes the next request; nothing here reads it.
const status = session.getPromptCacheStatus(nowMs, 0);
if (!status || status.inferred || status.state !== "warm" || status.ttlMs === undefined) return undefined;
return { tier: status.tier, touchedAt: status.touchedAt, ttlMs: status.ttlMs };
}

export type PromptCacheMessageEnd = Extract<AgentSessionEvent, { type: "message_end" }> & {
promptCache: RpcPromptCacheEntry;
};

/** `event`, with the confirmed entry on an assistant reply's `message_end`. */
export function withPromptCache(
event: AgentSessionEvent,
session: PromptCacheSession,
): AgentSessionEvent | PromptCacheMessageEnd {
if (event.type !== "message_end" || event.message.role !== "assistant") return event;
const promptCache = confirmedPromptCache(session);
return promptCache === undefined ? event : { ...event, promptCache };
}
116 changes: 116 additions & 0 deletions packages/coding-agent/test/scient/prompt-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import * as path from "node:path";
import { Agent } from "@oh-my-pi/pi-agent-core";
import type { AssistantMessage, Model, Usage } from "@oh-my-pi/pi-ai";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { withPromptCache } from "@oh-my-pi/pi-coding-agent/scient/prompt-cache";
import { SecretObfuscator } from "@oh-my-pi/pi-coding-agent/secrets";
import { AgentSession, type AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { CacheWarmer } from "@oh-my-pi/pi-coding-agent/session/cache-warmer";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { TempDir } from "@oh-my-pi/pi-utils";

const LONG_TTL_MS = 3_600_000;

function reply(model: Model): AssistantMessage {
return {
role: "assistant",
content: [{ type: "text", text: "done" }],
api: model.api,
provider: model.provider,
model: model.id,
stopReason: "stop",
timestamp: Date.now(),
usage: {
input: 1,
output: 1,
cacheRead: 0,
cacheWrite: 250_000,
totalTokens: 250_002,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
} satisfies Usage,
};
}

describe("message_end carries the confirmed prompt-cache entry", () => {
let tempDir: TempDir;
let authStorage: AuthStorage | undefined;
let session: AgentSession;
let warmer: CacheWarmer;

async function createSession(model: Model): Promise<void> {
warmer?.cancel();
authStorage?.close();
const agentDir = tempDir.path();
authStorage = await AuthStorage.create(path.join(agentDir, "auth.db"));
const settings = await Settings.loadIsolated({ agentDir, cwd: agentDir });
warmer = new CacheWarmer({
getMode: () => "off",
getPromptTokens: () => 250_000,
stream: async () => {
throw new Error("no refresh expected");
},
});
session = new AgentSession({
agent: new Agent({ initialState: { model, systemPrompt: ["Test"], tools: [], messages: [] } }),
sessionManager: SessionManager.create(agentDir, agentDir),
settings,
cacheWarmer: warmer,
modelRegistry: new ModelRegistry(authStorage),
obfuscator: new SecretObfuscator([]),
});
}

/** One main-loop request whose response wrote the prompt cache; returns its message_end. */
function respond(model: Model): AgentSessionEvent {
session.startCacheWarming(model, { messages: [] }, { cacheRetention: "long" });
const message = reply(model);
warmer.onResponse(message);
return { type: "message_end", message };
}

beforeEach(() => {
vi.useFakeTimers();
tempDir = TempDir.createSync("@scient-prompt-cache-");
});

afterEach(async () => {
warmer.cancel();
vi.useRealTimers();
authStorage?.close();
authStorage = undefined;
try {
await tempDir.remove();
} catch {}
});

it("reports a confirmed one-hour entry from the request's send time, until it lapses", async () => {
const model = { ...getBundledModel("anthropic", "claude-sonnet-4-5"), promptCache: { long: 3600 } } as Model;
await createSession(model);
const sentAt = Date.now();
const event = respond(model);

expect(withPromptCache(event, session)).toMatchObject({
promptCache: { tier: "long", touchedAt: sentAt, ttlMs: LONG_TTL_MS },
});
vi.advanceTimersByTime(LONG_TTL_MS);
expect(withPromptCache(event, session)).not.toHaveProperty("promptCache");
});

it("reports nothing for a cache that never expires, or on a frame other than a reply's end", async () => {
const codex = getBundledModel("openai-codex", "gpt-5.6-sol") as Model;
await createSession(codex);
expect(withPromptCache(respond(codex), session)).not.toHaveProperty("promptCache");

const model = { ...getBundledModel("anthropic", "claude-sonnet-4-5"), promptCache: { long: 3600 } } as Model;
await createSession(model);
const end = respond(model);
if (end.type !== "message_end") throw new Error("expected message_end");
expect(withPromptCache({ type: "message_start", message: end.message }, session)).not.toHaveProperty(
"promptCache",
);
});
});
10 changes: 10 additions & 0 deletions scripts/scient/divergences.json
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,16 @@
],
"upstream": ["packages/coding-agent/src/cli-commands.ts"],
"verification": ["bun --cwd=packages/coding-agent test test/scient/dictation.test.ts"]
},
"SA-PROMPT-CACHE": {
"status": "active",
"contract": "An assistant reply's message_end frame carries promptCache ({ tier, touchedAt, ttlMs }), the prompt-cache entry the reply's response confirmed, while the next request would still read it. It is absent for an inferred entry, a changed conversation, a model without a declared lifetime, and a cache that never expires. The agent's own idle compaction stays off over RPC, so a host that compacts before the entry expires never races it. Hosts that do not read it are unaffected.",
"owned": ["packages/coding-agent/src/scient/prompt-cache.ts", "packages/coding-agent/test/scient/prompt-cache.test.ts"],
"upstream": [
"packages/coding-agent/src/modes/rpc/rpc-mode.ts",
"packages/coding-agent/src/modes/rpc/wire/events.ts"
],
"verification": ["bun --cwd=packages/coding-agent test test/scient/prompt-cache.test.ts"]
}
}
}
49 changes: 49 additions & 0 deletions sdk/go/omp-rpc/wire.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading