diff --git a/SCIENT.md b/SCIENT.md index dfca20582a..b6dbf8055c 100644 --- a/SCIENT.md +++ b/SCIENT.md @@ -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. | diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 6d76396f75..1a45c46654 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -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"; @@ -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); diff --git a/packages/coding-agent/src/modes/rpc/wire/events.ts b/packages/coding-agent/src/modes/rpc/wire/events.ts index 9c70cdd629..5e59a92dbb 100644 --- a/packages/coding-agent/src/modes/rpc/wire/events.ts +++ b/packages/coding-agent/src/modes/rpc/wire/events.ts @@ -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", diff --git a/packages/coding-agent/src/modes/rpc/wire/rpc-wire.generated.ts b/packages/coding-agent/src/modes/rpc/wire/rpc-wire.generated.ts index efaf398693..3b91dd8665 100644 --- a/packages/coding-agent/src/modes/rpc/wire/rpc-wire.generated.ts +++ b/packages/coding-agent/src/modes/rpc/wire/rpc-wire.generated.ts @@ -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 { diff --git a/packages/coding-agent/src/modes/rpc/wire/rpc-wire.schema.json b/packages/coding-agent/src/modes/rpc/wire/rpc-wire.schema.json index ec6c64dcf9..8a5b353de9 100644 --- a/packages/coding-agent/src/modes/rpc/wire/rpc-wire.schema.json +++ b/packages/coding-agent/src/modes/rpc/wire/rpc-wire.schema.json @@ -3905,6 +3905,10 @@ "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": [ @@ -3912,6 +3916,32 @@ "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": { diff --git a/packages/coding-agent/src/scient/prompt-cache.ts b/packages/coding-agent/src/scient/prompt-cache.ts new file mode 100644 index 0000000000..49cf011104 --- /dev/null +++ b/packages/coding-agent/src/scient/prompt-cache.ts @@ -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; + +/** 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 & { + 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 }; +} diff --git a/packages/coding-agent/test/scient/prompt-cache.test.ts b/packages/coding-agent/test/scient/prompt-cache.test.ts new file mode 100644 index 0000000000..afebd6b8f9 --- /dev/null +++ b/packages/coding-agent/test/scient/prompt-cache.test.ts @@ -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 { + 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", + ); + }); +}); diff --git a/scripts/scient/divergences.json b/scripts/scient/divergences.json index c355afce3e..c265c3691f 100644 --- a/scripts/scient/divergences.json +++ b/scripts/scient/divergences.json @@ -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"] } } } diff --git a/sdk/go/omp-rpc/wire.go b/sdk/go/omp-rpc/wire.go index 199fe673e7..bbfe90d937 100644 --- a/sdk/go/omp-rpc/wire.go +++ b/sdk/go/omp-rpc/wire.go @@ -3841,6 +3841,8 @@ type MessageEndEvent struct { Message AgentMessage `json:"message"` // Shared by the start, updates, and end of one message; unique per process. MessageID *string `json:"messageId,omitempty"` + // 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 `json:"promptCache,omitempty"` } func (v *MessageEndEvent) UnmarshalJSON(data []byte) error { @@ -3853,6 +3855,7 @@ func (v *MessageEndEvent) decodeFrom(raw map[string]json.RawMessage) error { d.constant("type", "message_end") d.required("message", &out.Message) d.optional("messageId", &out.MessageID) + d.optional("promptCache", &out.PromptCache) if d.err != nil { return d.err } @@ -3865,6 +3868,52 @@ func (v MessageEndEvent) MarshalJSON() ([]byte, error) { return encodeObject(plain(v), `"type":"message_end"`, nil) } +type PromptCacheEntry struct { + // Retention tier the provider wrote the entry under: minutes or about an hour. + Tier PromptCacheEntryTier `json:"tier"` + // Epoch ms when a request last wrote or read the entry (its send time). + TouchedAt int64 `json:"touchedAt"` + // How long the provider keeps the entry after `touchedAt`. + TtlMs int64 `json:"ttlMs"` +} + +func (v *PromptCacheEntry) UnmarshalJSON(data []byte) error { + return decodeWith(data, "PromptCacheEntry", v.decodeFrom) +} + +func (v *PromptCacheEntry) decodeFrom(raw map[string]json.RawMessage) error { + var out PromptCacheEntry + d := fieldDecoder{raw: raw, owner: "PromptCacheEntry"} + d.required("tier", &out.Tier) + d.required("touchedAt", &out.TouchedAt) + d.required("ttlMs", &out.TtlMs) + if d.err != nil { + return d.err + } + *v = out + return nil +} + +type PromptCacheEntryTier string + +const ( + PromptCacheEntryTierShort PromptCacheEntryTier = "short" + PromptCacheEntryTierLong PromptCacheEntryTier = "long" +) + +func (v *PromptCacheEntryTier) UnmarshalJSON(data []byte) error { + s, err := decodeString(data, "PromptCacheEntryTier") + if err != nil { + return err + } + switch value := PromptCacheEntryTier(s); value { + case PromptCacheEntryTierShort, PromptCacheEntryTierLong: + *v = value + return nil + } + return unknownValue("PromptCacheEntryTier", s) +} + type ToolExecutionStartEvent struct { ToolCallID string `json:"toolCallId"` ToolName string `json:"toolName"` diff --git a/sdk/python/omp-rpc/src/omp_rpc/_wire.py b/sdk/python/omp-rpc/src/omp_rpc/_wire.py index c3076046e8..f671cfa940 100644 --- a/sdk/python/omp-rpc/src/omp_rpc/_wire.py +++ b/sdk/python/omp-rpc/src/omp_rpc/_wire.py @@ -1049,6 +1049,18 @@ class MessageEndEvent: message: AgentMessage message_id: str | None = None """Shared by the start, updates, and end of one message; unique per process.""" + prompt_cache: PromptCacheEntry | None = None + """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.""" + + +@dataclass(slots=True, frozen=True, kw_only=True) +class PromptCacheEntry: + tier: Literal["short", "long"] + """Retention tier the provider wrote the entry under: minutes or about an hour.""" + touched_at: int + """Epoch ms when a request last wrote or read the entry (its send time).""" + ttl_ms: int + """How long the provider keeps the entry after `touchedAt`.""" @dataclass(slots=True, frozen=True, kw_only=True) @@ -2495,6 +2507,16 @@ def parse_message_end_event(value: object, path: str = "MessageEndEvent") -> Mes return MessageEndEvent( message=required(payload, "message", parse_agent_message, path), message_id=optional(payload, "messageId", decode_str, path), + prompt_cache=optional(payload, "promptCache", parse_prompt_cache_entry, path), + ) + + +def parse_prompt_cache_entry(value: object, path: str = "PromptCacheEntry") -> PromptCacheEntry: + payload = expect_object(value, path) + return PromptCacheEntry( + tier=required(payload, "tier", cast('Decoder[Literal["short", "long"]]', literal(frozenset({"short", "long"}))), path), + touched_at=required(payload, "touchedAt", decode_int, path), + ttl_ms=required(payload, "ttlMs", decode_int, path), ) @@ -4207,6 +4229,7 @@ def on_queue_update(self, listener: Callable[[QueueUpdateEvent], None]) -> Calla "ParentIrc", "PromoteQueuedMessageResult", "PromptAck", + "PromptCacheEntry", "PromptError", "PromptResultEvent", "PromptStatus", @@ -4393,6 +4416,7 @@ def on_queue_update(self, listener: Callable[[QueueUpdateEvent], None]) -> Calla "parse_parent_irc", "parse_promote_queued_message_result", "parse_prompt_ack", + "parse_prompt_cache_entry", "parse_prompt_error", "parse_prompt_result_event", "parse_python_execution_message", diff --git a/sdk/rust/omp-rpc/src/wire.rs b/sdk/rust/omp-rpc/src/wire.rs index 233d10d16f..14df3f071f 100644 --- a/sdk/rust/omp-rpc/src/wire.rs +++ b/sdk/rust/omp-rpc/src/wire.rs @@ -3768,6 +3768,21 @@ pub struct MessageEndEvent { /// Shared by the start, updates, and end of one message; unique per process. #[serde(rename = "messageId", default, skip_serializing_if = "Option::is_none")] pub message_id: Option, + /// 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. + #[serde(rename = "promptCache", default, skip_serializing_if = "Option::is_none")] + pub prompt_cache: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PromptCacheEntry { + /// Retention tier the provider wrote the entry under: minutes or about an hour. + pub tier: PromptCacheEntryTier, + /// Epoch ms when a request last wrote or read the entry (its send time). + #[serde(rename = "touchedAt")] + pub touched_at: i64, + /// How long the provider keeps the entry after `touchedAt`. + #[serde(rename = "ttlMs")] + pub ttl_ms: i64, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -6070,6 +6085,24 @@ impl LogoutAccountType { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum PromptCacheEntryTier { + #[serde(rename = "short")] + Short, + #[serde(rename = "long")] + Long, +} + +impl PromptCacheEntryTier { + /// Wire value. + pub fn as_str(self) -> &'static str { + match self { + Self::Short => "short", + Self::Long => "long", + } + } +} + /// The constant `"cost_cap"`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] pub struct LitCostCap;