diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md
index 3a68b2598..f96870486 100644
--- a/packages/agent/CHANGELOG.md
+++ b/packages/agent/CHANGELOG.md
@@ -1,6 +1,13 @@
# Changelog
## [Unreleased]
+### Added
+
+- Added `buildSideRequestContext` to the `Agent` class to build prompt-cache-friendly provider Contexts for side-channels or ephemeral requests.
+
+### Changed
+
+- Exported helper functions `normalizeMessagesForProvider` and `resolveOwnedDialectFromEnv` from `packages/agent/src/agent-loop.ts`.
## [16.1.5] - 2026-06-19
@@ -901,4 +908,4 @@ Initial release under @oh-my-pi scope. See previous releases at [badlogic/pi-mon
### Changed
- `Agent` constructor now has all options optional (empty options use defaults).
-- `queueMessage()` is now synchronous (no longer returns a Promise).
+- `queueMessage()` is now synchronous (no longer returns a Promise).
\ No newline at end of file
diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts
index 70af60df3..c3336c8b2 100644
--- a/packages/agent/src/agent-loop.ts
+++ b/packages/agent/src/agent-loop.ts
@@ -121,7 +121,7 @@ class HarmonyLeakInterruption extends Error {
this.name = "HarmonyLeakInterruption";
}
}
-function resolveOwnedDialectFromEnv(value: string | undefined): Dialect | undefined {
+export function resolveOwnedDialectFromEnv(value: string | undefined): Dialect | undefined {
switch (value) {
case "1":
case "true":
@@ -502,7 +502,7 @@ function createDetailedCapture(config: AgentLoopConfig): {
};
}
-function normalizeMessagesForProvider(
+export function normalizeMessagesForProvider(
messages: Context["messages"],
model: AgentLoopConfig["model"],
): Context["messages"] {
diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts
index adb099605..58fdde451 100644
--- a/packages/agent/src/agent.ts
+++ b/packages/agent/src/agent.ts
@@ -24,9 +24,17 @@ import {
} from "@oh-my-pi/pi-ai";
import type { Dialect } from "@oh-my-pi/pi-ai/dialect";
import type { HarmonyAuditEvent } from "@oh-my-pi/pi-ai/utils/harmony-leak";
+import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import { logger } from "@oh-my-pi/pi-utils";
-import { abortReasonText, agentLoop, agentLoopContinue } from "./agent-loop";
+import {
+ abortReasonText,
+ agentLoop,
+ agentLoopContinue,
+ normalizeMessagesForProvider,
+ normalizeTools,
+ resolveOwnedDialectFromEnv,
+} from "./agent-loop";
import type { AppendOnlyContextManager } from "./append-only-context";
import type {
AgentContext,
@@ -662,6 +670,32 @@ export class Agent {
this.#appendOnlyContext = manager;
}
+ /**
+ * Assemble the provider Context for a side-channel (no-loop) request, mirroring
+ * the main loop's prefix (system + normalized tools) so it shares the prompt
+ * cache. Never touches the append-only log or the tool-choice queue. Owned/
+ * in-band dialect sessions stay tools-less (matching their no-native-tools wire
+ * shape and avoiding tool-markup leakage). `llmMessages` is already converted
+ * (and, in production, obfuscated) by the caller.
+ */
+ buildSideRequestContext(llmMessages: Message[]): Context {
+ const model = this.#state.model;
+ if (!model) throw new Error("No active model on agent");
+ const ownedDialect = this.#dialect ?? resolveOwnedDialectFromEnv(Bun.env.PI_DIALECT);
+ const messages = normalizeMessagesForProvider(llmMessages, model);
+ const tools = ownedDialect
+ ? []
+ : (normalizeTools(
+ this.#state.tools,
+ this.#intentTracing,
+ preferredDialect(model.id),
+ this.#pruneToolDescriptions,
+ ) ?? []);
+ let context: Context = { systemPrompt: this.#state.systemPrompt, messages, tools };
+ if (this.#transformProviderContext) context = this.#transformProviderContext(context, model);
+ return context;
+ }
+
subscribe(fn: (e: AgentEvent) => void): () => void {
this.#listeners.add(fn);
return () => this.#listeners.delete(fn);
diff --git a/packages/agent/test/agent-side-request-context.test.ts b/packages/agent/test/agent-side-request-context.test.ts
new file mode 100644
index 000000000..423756d3a
--- /dev/null
+++ b/packages/agent/test/agent-side-request-context.test.ts
@@ -0,0 +1,78 @@
+import { describe, expect, it, mock } from "bun:test";
+import { type Context, z } from "@oh-my-pi/pi-ai";
+import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock";
+import { Agent } from "../src/agent";
+import type { AgentTool } from "../src/types";
+
+describe("Agent — buildSideRequestContext", () => {
+ const model = createMockModel({ responses: [] });
+ const tool: AgentTool = {
+ name: "test_tool",
+ label: "Test Tool",
+ description: "a cool tool",
+ parameters: z.object({ arg: z.string() }) as unknown as AgentTool["parameters"],
+ execute: async () => ({ content: [{ type: "text", text: "success" }], details: { value: "success" } }),
+ };
+
+ it("forwards the tool catalog for native providers", () => {
+ const agent = new Agent({
+ initialState: {
+ model,
+ systemPrompt: ["system"],
+ tools: [tool],
+ },
+ });
+
+ const context = agent.buildSideRequestContext([
+ { role: "user", content: [{ type: "text", text: "Q?" }], timestamp: Date.now() },
+ ]);
+
+ expect(context.tools).toBeDefined();
+ expect(context.tools!.length).toBe(1);
+ expect(context.tools![0].name).toBe("test_tool");
+ expect(context.systemPrompt).toEqual(["system"]);
+ });
+
+ it("returns empty tools when owned dialect is active", () => {
+ const agent = new Agent({
+ initialState: {
+ model,
+ systemPrompt: ["system"],
+ tools: [tool],
+ },
+ dialect: "glm",
+ });
+
+ const context = agent.buildSideRequestContext([
+ { role: "user", content: [{ type: "text", text: "Q?" }], timestamp: Date.now() },
+ ]);
+
+ expect(context.tools).toEqual([]);
+ expect(context.systemPrompt).toEqual(["system"]);
+ });
+
+ it("invokes transformProviderContext filter if present", () => {
+ const transformSpy = mock((ctx: Context): Context => {
+ return {
+ ...ctx,
+ systemPrompt: ["transformed-system"],
+ };
+ });
+
+ const agent = new Agent({
+ initialState: {
+ model,
+ systemPrompt: ["system"],
+ tools: [tool],
+ },
+ transformProviderContext: transformSpy,
+ });
+
+ const context = agent.buildSideRequestContext([
+ { role: "user", content: [{ type: "text", text: "Q?" }], timestamp: Date.now() },
+ ]);
+
+ expect(transformSpy).toHaveBeenCalledTimes(1);
+ expect(context.systemPrompt).toEqual(["transformed-system"]);
+ });
+});
diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md
index 4a92272be..5969068b9 100644
--- a/packages/coding-agent/CHANGELOG.md
+++ b/packages/coding-agent/CHANGELOG.md
@@ -1,7 +1,6 @@
# Changelog
## [Unreleased]
-
### Added
- Added "Prose Only Thinking" setting to opt-out of rendering code blocks within AI thinking traces
@@ -11,6 +10,7 @@
### Changed
+- Changed side-channel turns (`/btw`, `/omfg`, and IRC auto-replies) to forward the main turn's tool catalog to preserve the prompt-cache layout, while injecting a reminder to suppress tool usage and discarding any generated tool calls.
- Changed `/btw`, `/tan`, `/omfg`, `/memory`, `/rename`, and `/move` to save the typed command text to TUI prompt history so they can be recalled with the up arrow.
- Changed the temporary model picker to label Alt+P selections as session-only and point users to Alt+M or `/model` for role model assignment. ([#2952](https://github.com/can1357/oh-my-pi/issues/2952))
- Replaced `new Promise((resolve, reject) => ...)` in `AsyncDrain` with `Promise.withResolvers()` per the repo's promise-construction convention
diff --git a/packages/coding-agent/src/prompts/system/side-channel-no-tools.md b/packages/coding-agent/src/prompts/system/side-channel-no-tools.md
new file mode 100644
index 000000000..698841caf
--- /dev/null
+++ b/packages/coding-agent/src/prompts/system/side-channel-no-tools.md
@@ -0,0 +1,3 @@
+
+This is an ephemeral side-channel turn that reuses the current conversation's context. The tool catalog stays attached only to keep the prompt cache warm — tools are NOT available on this turn. Do NOT emit any tool call; reply with plain text only. Any tool call you produce is discarded without executing.
+
diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts
index 1e657928e..b68015b2f 100644
--- a/packages/coding-agent/src/session/agent-session.ts
+++ b/packages/coding-agent/src/session/agent-session.ts
@@ -74,7 +74,6 @@ import {
import type { ProtectedToolMatcher } from "@oh-my-pi/pi-agent-core/compaction/tool-protection";
import type {
AssistantMessage,
- Context,
ImageContent,
Message,
MessageAttribution,
@@ -229,6 +228,7 @@ import planModeReferencePrompt from "../prompts/system/plan-mode-reference.md" w
import planModeToolDecisionReminderPrompt from "../prompts/system/plan-mode-tool-decision-reminder.md" with {
type: "text",
};
+import sideChannelNoToolsReminder from "../prompts/system/side-channel-no-tools.md" with { type: "text" };
import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" };
import ttsrToolReminderTemplate from "../prompts/system/ttsr-tool-reminder.md" with { type: "text" };
import unexpectedStopRetryTemplate from "../prompts/system/unexpected-stop-retry.md" with { type: "text" };
@@ -11245,12 +11245,14 @@ export class AgentSession {
/**
* Run a single ephemeral side-channel turn against this session's current
- * model + system prompt + history. No tools are used; the side request
- * does not block on, or interfere with, any in-flight main turn. The
+ * model + system prompt + history. The main turn's tool catalog is sent
+ * to preserve the prompt cache, but the model is reminded not to call
+ * tools and any tool calls are discarded. The side request
+ * does not block on, or interfere with, any in-flight main turn. The
* session's history and persisted state are NOT modified by this call.
*
* Used by `BtwController` (`/btw`) and `OmfgController` (`/omfg`) to share
- * the snapshot + stream pipeline. The snapshot includes any in-flight
+ * the snapshot + stream pipeline. The snapshot includes any in-flight
* streaming assistant text so the model sees the half-finished response
* rather than missing context.
*/
@@ -11267,15 +11269,7 @@ export class AgentSession {
const cacheSessionId = this.sessionId;
const snapshot = this.#buildEphemeralSnapshot(args.promptText);
const llmMessages = await this.convertMessagesToLlm(snapshot, args.signal);
- const context: Context = {
- systemPrompt: this.systemPrompt,
- messages: llmMessages,
- // Empty tools array: with toolChoice="none" some encoders still serialize the
- // recipient's tool catalog and the model leaks raw call markup
- // (, DSML envelopes) into IRC replies. Stripping tools here
- // removes the surface entirely.
- tools: [],
- };
+ const context = this.agent.buildSideRequestContext(llmMessages);
const options = this.prepareSimpleStreamOptions(
{
apiKey: this.#modelRegistry.resolver(model, cacheSessionId),
@@ -11291,7 +11285,6 @@ export class AgentSession {
hideThinkingSummary: this.agent.hideThinkingSummary,
serviceTier: this.#effectiveServiceTier(model),
signal: args.signal,
- toolChoice: "none",
},
model.provider,
);
@@ -11331,9 +11324,13 @@ export class AgentSession {
if (args.onTextDelta && replyText.length > emittedReplyText.length) {
args.onTextDelta(replyText.slice(emittedReplyText.length));
}
+ const sanitizedMessage: AssistantMessage = {
+ ...assistantMessage,
+ content: assistantMessage.content.filter(block => block.type !== "toolCall"),
+ };
return {
replyText: args.dedupeReply === false ? replyText.trim() : dedupeEphemeralReply(replyText.trim()),
- assistantMessage,
+ assistantMessage: sanitizedMessage,
};
}
@@ -11374,6 +11371,12 @@ export class AgentSession {
}
}
}
+ messages.push({
+ role: "developer",
+ content: [{ type: "text", text: sideChannelNoToolsReminder }],
+ attribution: "agent",
+ timestamp: Date.now(),
+ });
messages.push({
role: "user",
content: [{ type: "text", text: promptText }],
diff --git a/packages/coding-agent/test/agent-session-message-pipeline.test.ts b/packages/coding-agent/test/agent-session-message-pipeline.test.ts
index 5c1b81910..c755ac5f9 100644
--- a/packages/coding-agent/test/agent-session-message-pipeline.test.ts
+++ b/packages/coding-agent/test/agent-session-message-pipeline.test.ts
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
-import { Agent, type AgentMessage, AppendOnlyContextManager } from "@oh-my-pi/pi-agent-core";
+import { Agent, type AgentMessage, type AgentTool, AppendOnlyContextManager } from "@oh-my-pi/pi-agent-core";
import {
type Api,
type Context,
@@ -874,4 +874,135 @@ describe("AgentSession message pipeline", () => {
const occurrences = forkedPrompt.split(injected).length - 1;
expect(occurrences).toBe(1);
});
+
+ it("ephemeral side-channel forwards native tools, injects developer reminder, leaves toolChoice auto", async () => {
+ const api = "test-ephemeral-tools-warm-cache";
+ let capturedContext: Context | undefined;
+ let capturedOptions: SimpleStreamOptions | undefined;
+ registerCustomApi(api, (_model, context, options) => {
+ capturedContext = context;
+ capturedOptions = options;
+ const stream = new AssistantMessageEventStream();
+ queueMicrotask(() => {
+ const message = createAssistantMessage("Not using tools");
+ stream.push({ type: "text_delta", contentIndex: 0, delta: "Not using tools", partial: message });
+ stream.push({ type: "done", reason: "stop", message });
+ });
+ return stream;
+ });
+
+ const model = buildModel({
+ id: "side-model-with-tools",
+ name: "Side Model with Tools",
+ api,
+ provider: "test-provider",
+ baseUrl: "",
+ reasoning: false,
+ input: ["text"],
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+ contextWindow: 4096,
+ maxTokens: 1024,
+ } as ModelSpec) as Model;
+
+ const tool: AgentTool = {
+ name: "side_tool",
+ label: "Side Tool",
+ description: "A tool in side channel",
+ parameters: { type: "object", properties: {} },
+ execute: async () => ({ content: [], details: {} }),
+ };
+
+ const session = new AgentSession({
+ agent: new Agent({
+ initialState: {
+ model,
+ systemPrompt: ["system prompt"],
+ messages: [],
+ tools: [tool],
+ },
+ }),
+ sessionManager: SessionManager.inMemory(),
+ settings: Settings.isolated({ "compaction.enabled": false }),
+ modelRegistry: createModelRegistryStub() as never,
+ });
+ sessions.push(session);
+
+ const result = await session.runEphemeralTurn({ promptText: "Side Question?" });
+
+ expect(result.replyText).toBe("Not using tools");
+ expect(capturedContext).toBeDefined();
+ expect(capturedContext!.tools).toBeDefined();
+ expect(capturedContext!.tools!.length).toBe(1);
+ expect(capturedContext!.tools![0].name).toBe("side_tool");
+
+ // Developer reminder injected immediately before user prompt
+ const messages = capturedContext!.messages;
+ expect(messages.length).toBeGreaterThanOrEqual(2);
+ const lastMessage = messages.at(-1);
+ const secondToLast = messages.at(-2);
+
+ expect(lastMessage?.role).toBe("user");
+ expect(getConvertedUserText(lastMessage)).toBe("Side Question?");
+
+ expect(secondToLast?.role).toBe("developer");
+ expect(secondToLast?.content).toBeDefined();
+ const textContent = secondToLast?.content as { text?: string }[];
+ expect(textContent[0].text).toContain("tool catalog stays attached");
+
+ // Tool choice must be undefined (not "none") for cache hits
+ expect(capturedOptions?.toolChoice).toBeUndefined();
+ });
+
+ it("ephemeral side-channel discards any emitted tool calls", async () => {
+ const api = "test-ephemeral-tools-discard";
+ registerCustomApi(api, (_model, _context, _options) => {
+ const stream = new AssistantMessageEventStream();
+ queueMicrotask(() => {
+ const message = createAssistantMessage("Here is text");
+ message.content.push({
+ type: "toolCall",
+ id: "call_123",
+ name: "side_tool",
+ arguments: {},
+ });
+ stream.push({ type: "text_delta", contentIndex: 0, delta: "Here is text", partial: message });
+ stream.push({ type: "done", reason: "stop", message });
+ });
+ return stream;
+ });
+
+ const model = buildModel({
+ id: "side-model-discard",
+ name: "Side Model Discard",
+ api,
+ provider: "test-provider",
+ baseUrl: "",
+ reasoning: false,
+ input: ["text"],
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+ contextWindow: 4096,
+ maxTokens: 1024,
+ } as ModelSpec) as Model;
+
+ const session = new AgentSession({
+ agent: new Agent({
+ initialState: {
+ model,
+ systemPrompt: ["system prompt"],
+ messages: [],
+ tools: [],
+ },
+ }),
+ sessionManager: SessionManager.inMemory(),
+ settings: Settings.isolated({ "compaction.enabled": false }),
+ modelRegistry: createModelRegistryStub() as never,
+ });
+ sessions.push(session);
+
+ const result = await session.runEphemeralTurn({ promptText: "Side Question?" });
+
+ expect(result.replyText).toBe("Here is text");
+ expect(result.assistantMessage.content.some(block => block.type === "toolCall")).toBe(false);
+ expect(result.assistantMessage.content.every(block => block.type !== "toolCall")).toBe(true);
+ });
});