diff --git a/api/server/controllers/agents/__tests__/callbacks.spec.js b/api/server/controllers/agents/__tests__/callbacks.spec.js
index 541edfdeb2..9b1f3b6eb7 100644
--- a/api/server/controllers/agents/__tests__/callbacks.spec.js
+++ b/api/server/controllers/agents/__tests__/callbacks.spec.js
@@ -1,4 +1,4 @@
-const { Tools } = require('librechat-data-provider');
+const { Tools, StepEvents } = require('librechat-data-provider');
// Mock all dependencies before requiring the module
jest.mock('nanoid', () => ({
@@ -144,6 +144,82 @@ describe('resumable event generation fencing', () => {
});
});
+describe('createPtcProgressEmitter', () => {
+ const ptcEvent = {
+ tool_call_id: 'call_ptc',
+ call_id: 'call_ptc:0',
+ name: 'read_file',
+ status: 'running',
+ args: 'path=a.ts',
+ };
+
+ beforeEach(() => jest.clearAllMocks());
+
+ it('emits the inner tool-call event on the resumable job stream', () => {
+ const { GenerationJobManager } = require('@librechat/api');
+ const { createPtcProgressEmitter } = require('../callbacks');
+ const emit = createPtcProgressEmitter({
+ res: { write: jest.fn() },
+ streamId: 'conversation-1',
+ jobCreatedAt: 1234,
+ });
+
+ emit(ptcEvent);
+
+ expect(GenerationJobManager.emitChunk).toHaveBeenCalledWith(
+ 'conversation-1',
+ { event: StepEvents.ON_PTC_TOOL_CALL, data: ptcEvent },
+ { expectedCreatedAt: 1234 },
+ );
+ });
+
+ it('writes to the live response when no stream id is in play', () => {
+ const { sendEvent } = require('@librechat/api');
+ const { createPtcProgressEmitter } = require('../callbacks');
+ const res = { write: jest.fn(), headersSent: true, writableEnded: false };
+ const emit = createPtcProgressEmitter({ res });
+
+ emit(ptcEvent);
+
+ expect(sendEvent).toHaveBeenCalledWith(res, {
+ event: StepEvents.ON_PTC_TOOL_CALL,
+ data: ptcEvent,
+ });
+ });
+
+ it('absorbs a rejected resumable emit instead of leaving an unhandled rejection', async () => {
+ const { GenerationJobManager } = require('@librechat/api');
+ const { createPtcProgressEmitter } = require('../callbacks');
+ GenerationJobManager.emitChunk.mockRejectedValueOnce(new Error('transport down'));
+ const unhandled = jest.fn();
+ process.on('unhandledRejection', unhandled);
+
+ const emit = createPtcProgressEmitter({
+ res: { write: jest.fn() },
+ streamId: 'conversation-1',
+ jobCreatedAt: 1234,
+ });
+
+ expect(() => emit(ptcEvent)).not.toThrow();
+ await new Promise((resolve) => setImmediate(resolve));
+ process.off('unhandledRejection', unhandled);
+
+ expect(unhandled).not.toHaveBeenCalled();
+ });
+
+ it('drops the event once the response has closed', () => {
+ const { sendEvent } = require('@librechat/api');
+ const { createPtcProgressEmitter } = require('../callbacks');
+ const emit = createPtcProgressEmitter({
+ res: { write: jest.fn(), headersSent: true, writableEnded: true },
+ });
+
+ emit(ptcEvent);
+
+ expect(sendEvent).not.toHaveBeenCalled();
+ });
+});
+
describe('createToolEndCallback', () => {
let req, res, artifactPromises, createToolEndCallback;
let logger;
diff --git a/api/server/controllers/agents/callbacks.js b/api/server/controllers/agents/callbacks.js
index 1eb56cf284..7e6f5f4af7 100644
--- a/api/server/controllers/agents/callbacks.js
+++ b/api/server/controllers/agents/callbacks.js
@@ -1084,6 +1084,44 @@ function createAttachmentEmitter({ res, streamId = null, jobCreatedAt }) {
};
}
+/**
+ * Streams `on_ptc_tool_call` lifecycle events for the tool calls a
+ * programmatic tool-calling program makes from inside the sandbox. Those
+ * inner calls open no run step of their own, so without this the card shows
+ * a running spinner for the whole program with no sign of what it is doing.
+ *
+ * Fire-and-forget like the attachment emitter: a closed stream drops the
+ * event rather than failing the tool call that produced it.
+ *
+ * @param {Object} params
+ * @param {ServerResponse} params.res
+ * @param {string | null} [params.streamId]
+ * @param {number} [params.jobCreatedAt]
+ * @returns {(event: import('librechat-data-provider').PtcToolCallEvent) => void}
+ */
+function createPtcProgressEmitter({ res, streamId = null, jobCreatedAt }) {
+ return (event) => {
+ if (!event || !isStreamWritable(res, streamId)) {
+ return;
+ }
+ const payload = { event: StepEvents.ON_PTC_TOOL_CALL, data: event };
+ if (streamId) {
+ /* Absorb a rejected transport here. The emitter is called from a
+ * synchronous try/catch inside `instrumentPtcToolMap`, which cannot
+ * observe a rejected promise — without this catch a failed emit would
+ * surface as an unhandled rejection on every affected inner call
+ * instead of being dropped as the telemetry it is. */
+ Promise.resolve(
+ GenerationJobManager.emitChunk(streamId, payload, { expectedCreatedAt: jobCreatedAt }),
+ ).catch(() => {
+ /* dropped: the trace is best-effort */
+ });
+ return;
+ }
+ sendEvent(res, payload);
+ };
+}
+
/**
* Leading sub-second retries cover the common case of a fast background task
* settling moments before the dispatch turn finalizes its message row — an
@@ -1438,6 +1476,7 @@ module.exports = {
getDefaultHandlers,
createToolEndCallback,
createAttachmentEmitter,
+ createPtcProgressEmitter,
createBackgroundCodeResultHandler,
isStreamWritable,
markSummarizationUsage,
diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js
index 1e818b481a..4a3a5aef6e 100644
--- a/api/server/services/Endpoints/agents/initialize.js
+++ b/api/server/services/Endpoints/agents/initialize.js
@@ -39,6 +39,7 @@ const {
const {
createToolEndCallback,
createAttachmentEmitter,
+ createPtcProgressEmitter,
createBackgroundCodeResultHandler,
getDefaultHandlers,
} = require('~/server/controllers/agents/callbacks');
@@ -374,6 +375,7 @@ const initializeClient = async ({
updateToolCallResult: db.updateToolCallResult,
}),
emitAttachment: createAttachmentEmitter({ res, streamId, jobCreatedAt }),
+ emitPtcProgress: createPtcProgressEmitter({ res, streamId, jobCreatedAt }),
...getSkillToolDeps(),
};
diff --git a/api/server/services/Endpoints/agents/initialize.spec.js b/api/server/services/Endpoints/agents/initialize.spec.js
index 927291b6b1..66335332ef 100644
--- a/api/server/services/Endpoints/agents/initialize.spec.js
+++ b/api/server/services/Endpoints/agents/initialize.spec.js
@@ -49,6 +49,7 @@ const mockArtifactToolEndCallback = jest.fn();
jest.mock('~/server/controllers/agents/callbacks', () => ({
createToolEndCallback: jest.fn(() => mockArtifactToolEndCallback),
createAttachmentEmitter: jest.fn(() => jest.fn()),
+ createPtcProgressEmitter: jest.fn(() => jest.fn()),
createBackgroundCodeResultHandler: jest.fn(() => jest.fn()),
getDefaultHandlers: jest.fn((opts) => {
capturedDefaultHandlerOptions = opts;
@@ -206,6 +207,7 @@ describe('initializeClient — processAgent ACL gate', () => {
it('threads the owning job epoch into resumable event handlers', async () => {
const {
createAttachmentEmitter,
+ createPtcProgressEmitter,
createToolEndCallback,
} = require('~/server/controllers/agents/callbacks');
mockInitializeAgent.mockResolvedValue(makePrimaryConfig([]));
@@ -237,6 +239,13 @@ describe('initializeClient — processAgent ACL gate', () => {
streamId: 'conv_1',
jobCreatedAt: 1234,
});
+ /** The PTC trace emitter is generation-fenced like every other resumable
+ * emitter; a stale epoch would leak one run's inner calls into the next. */
+ expect(createPtcProgressEmitter).toHaveBeenCalledWith({
+ res: {},
+ streamId: 'conv_1',
+ jobCreatedAt: 1234,
+ });
mockLoadToolsForExecution.mockResolvedValue({ loadedTools: [], configurable: {} });
await capturedToolExecuteOptions.loadTools([], PRIMARY_ID);
diff --git a/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx b/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx
index 570e370370..b50dfcbaf6 100644
--- a/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx
+++ b/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx
@@ -14,6 +14,7 @@ import useFollowScroll from './useFollowScroll';
import { ERROR_PATTERNS } from './ExecuteCode';
import { AttachmentGroup } from './Attachment';
import { useToolCallIntent } from './intent';
+import PtcToolTrace from './PtcToolTrace';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
@@ -176,6 +177,11 @@ export default function BashCall({
)}
+
{hasOutput && backgroundHandle == null && (
{highlighted}
)}
+
{hasOutput && backgroundHandle == null && (
= {
+ running: '›',
+ success: '✓',
+ error: '✗',
+ interrupted: '?',
+};
+
+/** Spoken equivalent of the glyph. The glyph itself is decorative, and a
+ * fast call renders no duration, so without this a completed row would
+ * announce no outcome at all. */
+const STATUS_LABEL_KEYS: Record
= {
+ running: 'com_ui_ptc_trace_running',
+ success: 'com_ui_ptc_trace_done',
+ error: 'com_ui_ptc_trace_failed',
+ interrupted: 'com_ui_ptc_trace_interrupted',
+};
+
+/**
+ * One line of the trace. Reads as a shell transcript — status glyph, the tool
+ * being called, its arguments, and how long it took — with the failure message
+ * printed underneath the call that produced it, the way a CLI reports errors.
+ */
+function PtcTraceLine({ entry }: { entry: PtcTraceEntry }) {
+ const localize = useLocalize();
+ const { i18n } = useTranslation();
+ const mcpServerNames = useMCPServerNames();
+ const parsed = parseToolName(entry.name, mcpServerNames);
+ const running = entry.status === 'running';
+ const failed = entry.status === 'error';
+ const duration = isReportableRunStepDuration(entry.durationMs)
+ ? getRunStepDurationLabels(entry.durationMs as number, i18n.language)
+ : undefined;
+
+ return (
+
+
+
+ {STATUS_GLYPH[entry.status]}
+
+ {localize(STATUS_LABEL_KEYS[entry.status])}
+
+ {parsed.mcpServer && (
+ <>
+ {parsed.mcpServer}
+ ·
+ >
+ )}
+ {parsed.friendlyKey ? localize(parsed.friendlyKey) : parsed.toolName}
+
+ {entry.args ?? ''}
+ {duration ? (
+ <>
+
+ {localize(duration.key, duration.values)}
+
+
+ {localize(duration.announcedKey, duration.announcedValues)}
+
+ >
+ ) : (
+ /* The sr-only status above already speaks this cell's meaning. */
+
+ {running ? localize('com_ui_ptc_trace_running') : ''}
+
+ )}
+
+ {failed && (
+
+
+
+ {entry.error ?? localize('com_ui_ptc_trace_failed')}
+
+
+ )}
+
+ );
+}
+
+/**
+ * Live trace of the tool calls a programmatic tool-calling program makes from
+ * inside the sandbox, rendered under the code that is making them. Those inner
+ * calls open no run step and get no card of their own, so without this the
+ * card shows one running spinner for the whole program.
+ *
+ * Renders nothing when the tool call made no inner calls, which is every
+ * non-PTC code execution — callers don't need to know which kind they hold.
+ */
+export default function PtcToolTrace({
+ toolCallId,
+ expanded = false,
+ className,
+}: {
+ toolCallId?: string;
+ /** The host card's disclosure state — a collapsed pane must not be scrolled
+ * invisibly, or it opens at the tail instead of the first call. */
+ expanded?: boolean;
+ className?: string;
+}) {
+ const localize = useLocalize();
+ /** Scope to this card's own message: providers reuse `tool_call_id` across
+ * turns, so the raw id alone would show a later program's calls here. */
+ const { messageId } = useMessageContext();
+ const trace = useRecoilValue(
+ ptcTraceByToolCallId(toolCallId && messageId ? ptcTraceKey(messageId, toolCallId) : ''),
+ );
+
+ /** One character per row, so the pin re-fires both when a call is appended
+ * and when one settles — a settle can add an error line and change height. */
+ const followKey = `${trace.dropped}:${trace.entries.map((entry) => entry.status[0]).join('')}`;
+ const running = trace.entries.some((entry) => entry.status === 'running');
+ const { ref: listRef, onScroll } = useFollowScroll(
+ followKey,
+ running,
+ expanded,
+ );
+
+ if (trace.entries.length === 0) {
+ return null;
+ }
+
+ /** No background of its own: the pane inherits the card's surface in both
+ * themes, which is what the sibling output pane resolves to anyway. A
+ * `dark:` override here would step outside the semantic roles and lose the
+ * intended separation under a custom theme. */
+ return (
+
+
+ {localize('com_ui_ptc_trace_title')}
+
+
+ {/* The retained tail is a window, not the whole program — say so
+ rather than let the reader assume these are all the calls. */}
+ {trace.dropped > 0 && (
+ -
+ {localize('com_ui_ptc_trace_earlier', { count: trace.dropped })}
+
+ )}
+ {trace.entries.map((entry) => (
+
+ ))}
+
+
+ );
+}
diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/ExecuteCode.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/ExecuteCode.test.tsx
index 6e37ed1bab..8b0c39b14c 100644
--- a/client/src/components/Chat/Messages/Content/Parts/__tests__/ExecuteCode.test.tsx
+++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/ExecuteCode.test.tsx
@@ -1,6 +1,9 @@
import React from 'react';
import { RecoilRoot } from 'recoil';
import { render } from '@testing-library/react';
+import type { PtcTraceEntry } from '~/store/ptc';
+import { ptcTraceByToolCallId, ptcTraceKey } from '~/store/ptc';
+import { MessageContext } from '~/Providers/MessageContext';
import ExecuteCode from '../ExecuteCode';
import store from '~/store';
@@ -55,9 +58,14 @@ jest.mock('../useLazyHighlight', () => {
});
jest.mock('~/utils', () => ({
+ ...jest.requireActual('~/utils/toolLabels'),
+ ...jest.requireActual('~/utils/runStepDuration'),
cn: (...classes: Array) => classes.filter(Boolean).join(' '),
}));
+jest.mock('react-i18next', () => ({ useTranslation: () => ({ i18n: { language: 'en' } }) }));
+jest.mock('~/hooks/MCP', () => ({ useMCPServerNames: () => [] }));
+
/**
* jsdom has no layout, so the capped pane's scroll geometry is stubbed:
* `clientHeight` is fixed, `scrollHeight` either reads from mutable state
@@ -134,3 +142,43 @@ describe('ExecuteCode streaming follow-scroll', () => {
expect(state.writes).toHaveLength(0);
});
});
+
+describe('ExecuteCode programmatic tool trace', () => {
+ const TOOL_CALL_ID = 'call_ptc_1';
+ const MESSAGE_ID = 'response-msg-1';
+
+ const renderWithTrace = (entries: PtcTraceEntry[]) =>
+ render(
+ {
+ set(store.autoExpandTools, true);
+ set(ptcTraceByToolCallId(ptcTraceKey(MESSAGE_ID, TOOL_CALL_ID)), { entries, dropped: 0 });
+ }}
+ >
+
+
+
+ ,
+ );
+
+ it('lists the inner tool calls the running program has made', () => {
+ const { getByText } = renderWithTrace([
+ { callId: 'a', name: 'read_file', status: 'success', args: 'path=a.ts', durationMs: 1200 },
+ ]);
+
+ expect(getByText('read_file')).toBeInTheDocument();
+ expect(getByText('path=a.ts')).toBeInTheDocument();
+ });
+
+ it('adds nothing to a plain code execution that made no inner calls', () => {
+ const { queryByText } = renderWithTrace([]);
+
+ expect(queryByText('com_ui_ptc_trace_title')).not.toBeInTheDocument();
+ });
+});
diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/PtcToolTrace.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/PtcToolTrace.test.tsx
new file mode 100644
index 0000000000..bab6809622
--- /dev/null
+++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/PtcToolTrace.test.tsx
@@ -0,0 +1,140 @@
+import React from 'react';
+import { RecoilRoot, useSetRecoilState } from 'recoil';
+import { render, screen } from '@testing-library/react';
+import type { PtcTraceEntry } from '~/store/ptc';
+import { ptcTraceByToolCallId, ptcTraceKey } from '~/store/ptc';
+import { MessageContext } from '~/Providers/MessageContext';
+import PtcToolTrace from '../PtcToolTrace';
+
+jest.mock('~/hooks', () => ({
+ useLocalize:
+ () =>
+ (key: string, values?: Record): string => {
+ const translations: Record = {
+ com_ui_ptc_trace_title: 'Tool calls',
+ com_ui_ptc_trace_running: 'running',
+ com_ui_ptc_trace_failed: 'failed',
+ com_ui_ptc_trace_done: 'done',
+ com_ui_ptc_trace_interrupted: 'interrupted',
+ com_ui_ptc_trace_earlier: `+${values?.count} earlier calls`,
+ com_ui_tool_name_code: 'Code',
+ };
+ if (key === 'com_ui_duration_seconds') {
+ return `${values?.[0]}s`;
+ }
+ return translations[key] ?? key;
+ },
+}));
+
+jest.mock('react-i18next', () => ({ useTranslation: () => ({ i18n: { language: 'en' } }) }));
+jest.mock('~/hooks/MCP', () => ({ useMCPServerNames: () => ['github'] }));
+jest.mock('~/utils', () => ({
+ ...jest.requireActual('~/utils/toolLabels'),
+ ...jest.requireActual('~/utils/runStepDuration'),
+ cn: (...classes: unknown[]) => classes.filter(Boolean).join(' '),
+}));
+
+const TOOL_CALL_ID = 'call_ptc_1';
+const MESSAGE_ID = 'response-msg-1';
+
+function renderTrace(entries: PtcTraceEntry[], messageId = MESSAGE_ID, dropped = 0) {
+ const Seed = () => {
+ const set = useSetRecoilState(ptcTraceByToolCallId(ptcTraceKey(MESSAGE_ID, TOOL_CALL_ID)));
+ React.useEffect(() => {
+ if (entries.length > 0) {
+ set({ entries, dropped });
+ }
+ }, [set]);
+ return null;
+ };
+
+ return render(
+
+
+
+
+
+ ,
+ );
+}
+
+describe('PtcToolTrace', () => {
+ it('renders nothing when the call made no inner tool calls', () => {
+ const { container } = renderTrace([]);
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('lists each inner call with its arguments', () => {
+ renderTrace([
+ { callId: 'a', name: 'read_file', status: 'success', args: 'path=a.ts', durationMs: 1200 },
+ { callId: 'b', name: 'write_file', status: 'running', args: 'path=b.ts' },
+ ]);
+
+ expect(screen.getByText('Tool calls')).toBeInTheDocument();
+ expect(screen.getByText('read_file')).toBeInTheDocument();
+ expect(screen.getByText('path=a.ts')).toBeInTheDocument();
+ expect(screen.getByText('write_file')).toBeInTheDocument();
+ /* Twice by design: the visible cell plus the screen-reader status. */
+ expect(screen.getAllByText('running')).toHaveLength(2);
+ });
+
+ it('splits an MCP tool id into its server and tool name', () => {
+ renderTrace([{ callId: 'a', name: 'search_code_mcp_github', status: 'running' }]);
+
+ expect(screen.getByText('github')).toBeInTheDocument();
+ expect(screen.getByText('search_code')).toBeInTheDocument();
+ });
+
+ it('shows a settled duration', () => {
+ renderTrace([{ callId: 'a', name: 'read_file', status: 'success', durationMs: 1200 }]);
+
+ expect(screen.getByText('1.2s')).toBeInTheDocument();
+ });
+
+ it('prints the failure message under the call that produced it', () => {
+ renderTrace([
+ { callId: 'a', name: 'write_file', status: 'error', args: 'path=/etc/x', error: 'Denied' },
+ ]);
+
+ expect(screen.getByText('path=/etc/x')).toBeInTheDocument();
+ expect(screen.getByText('Denied')).toBeInTheDocument();
+ });
+
+ it('falls back to a generic failure label when no message came through', () => {
+ renderTrace([{ callId: 'a', name: 'write_file', status: 'error' }]);
+
+ expect(screen.getAllByText('failed').length).toBeGreaterThan(0);
+ });
+
+ it('speaks a completion status even when the call was too fast to time', () => {
+ renderTrace([{ callId: 'a', name: 'read_file', status: 'success' }]);
+
+ expect(screen.getByText('done')).toBeInTheDocument();
+ });
+
+ it('speaks an interrupted outcome for a call a stream gap cut off', () => {
+ renderTrace([{ callId: 'c1', name: 'write_file', status: 'interrupted' }]);
+ expect(screen.getByText('interrupted')).toBeInTheDocument();
+ /** Neither a failure nor still in flight: no error styling, no spinner. */
+ expect(screen.queryByText('failed')).not.toBeInTheDocument();
+ expect(screen.queryByText('running')).not.toBeInTheDocument();
+ });
+
+ it('says how many calls the cap dropped rather than truncating silently', () => {
+ renderTrace([{ callId: 'a', name: 'read_file', status: 'success' }], MESSAGE_ID, 42);
+
+ expect(screen.getByText('+42 earlier calls')).toBeInTheDocument();
+ });
+
+ it('shows no truncation notice when nothing was dropped', () => {
+ renderTrace([{ callId: 'a', name: 'read_file', status: 'success' }]);
+
+ expect(screen.queryByText(/earlier calls/)).not.toBeInTheDocument();
+ });
+
+ it('does not show another message\u2019s trace in this card', () => {
+ renderTrace([{ callId: 'a', name: 'read_file', status: 'success' }], 'a-different-message');
+
+ expect(screen.queryByText('read_file')).not.toBeInTheDocument();
+ });
+});
diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/useFollowScroll.spec.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/useFollowScroll.spec.tsx
index 8573d53b64..4a996c7ca9 100644
--- a/client/src/components/Chat/Messages/Content/Parts/__tests__/useFollowScroll.spec.tsx
+++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/useFollowScroll.spec.tsx
@@ -112,6 +112,34 @@ describe('useFollowScroll', () => {
expect(state.writes).toEqual([900]);
});
+ /** A failing PTC call settles by appending its error line in the same
+ * commit that clears the last running row, so the growth and the end of
+ * the stream arrive together. */
+ it('pins one last time when the change that ends the stream also adds content', () => {
+ const { rerender, state } = setup();
+ state.scrollHeight = 1200;
+ rerender();
+ expect(state.writes).toEqual([1200]);
+ });
+
+ it('does not pin the settling change for a reader who scrolled up', () => {
+ const { rerender, pane, state } = setup();
+ state.scrollTop = 100;
+ fireEvent.scroll(pane);
+ state.scrollHeight = 1200;
+ rerender();
+ expect(state.writes).toHaveLength(0);
+ });
+
+ it('pins only the settling change, not the ones after it', () => {
+ const { rerender, state } = setup();
+ rerender();
+ expect(state.writes).toEqual([900]);
+ state.scrollHeight = 1200;
+ rerender();
+ expect(state.writes).toEqual([900]);
+ });
+
it('detaches when the user scrolls up beyond the follow threshold', () => {
const { rerender, pane, state } = setup();
state.scrollTop = 100;
diff --git a/client/src/components/Chat/Messages/Content/Parts/index.ts b/client/src/components/Chat/Messages/Content/Parts/index.ts
index 5c6b6ae9a4..2deb26ef2e 100644
--- a/client/src/components/Chat/Messages/Content/Parts/index.ts
+++ b/client/src/components/Chat/Messages/Content/Parts/index.ts
@@ -13,6 +13,7 @@ export { default as SkillCall } from './SkillCall';
export { default as ReadFileCall } from './ReadFileCall';
export { default as FileAuthoringCall } from './FileAuthoringCall';
export { default as BashCall } from './BashCall';
+export { default as PtcToolTrace } from './PtcToolTrace';
export { default as SubagentCall } from './SubagentCall';
export { default as SteerPart } from './SteerPart';
export { default as AuthorHeader } from './AuthorHeader';
diff --git a/client/src/components/Chat/Messages/Content/Parts/useFollowScroll.ts b/client/src/components/Chat/Messages/Content/Parts/useFollowScroll.ts
index 2cf0d6ca4b..37c3c820d4 100644
--- a/client/src/components/Chat/Messages/Content/Parts/useFollowScroll.ts
+++ b/client/src/components/Chat/Messages/Content/Parts/useFollowScroll.ts
@@ -32,8 +32,16 @@ const FOLLOW_THRESHOLD_PX = 40;
* would open at the tail. Gated, it opens reading from the top, while an
* expand mid-stream pins immediately. The pin's own scroll event measures
* distance zero and keeps the attached state, so programmatic scrolls
- * need no special-casing. Finished panes (`active === false`) are never
- * scrolled.
+ * need no special-casing.
+ *
+ * A finished pane (`active === false`) is never scrolled, with one
+ * exception: the very change that ends the stream. That change often adds
+ * content — a failing PTC call settles by appending its error line in the
+ * same commit that clears the last running row — and gating on `active`
+ * alone would drop exactly the pin that content needs, leaving the final
+ * line below the fold. So the falling edge of `active` still pins, but
+ * only when the content changed with it; ending the stream on its own
+ * leaves the pane where the reader left it.
*/
export default function useFollowScroll(
content: string | readonly ReactNode[],
@@ -42,6 +50,8 @@ export default function useFollowScroll(
): { ref: RefObject; onScroll: UIEventHandler } {
const ref = useRef(null);
const followRef = useRef(true);
+ const wasActiveRef = useRef(active);
+ const previousContentRef = useRef(content);
const onScroll = useCallback>((event) => {
const el = event.currentTarget;
@@ -49,7 +59,14 @@ export default function useFollowScroll(
}, []);
useLayoutEffect(() => {
- if (!active || !expanded || !followRef.current) {
+ const settling = wasActiveRef.current && !Object.is(previousContentRef.current, content);
+ previousContentRef.current = content;
+ wasActiveRef.current = active;
+
+ if (!expanded || !followRef.current) {
+ return;
+ }
+ if (!active && !settling) {
return;
}
const el = ref.current;
diff --git a/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts b/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts
index 49fd41fe8e..514f6b6456 100644
--- a/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts
+++ b/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts
@@ -236,6 +236,7 @@ jest.mock('~/hooks/SSE/useEventHandlers', () => {
contentHandler: jest.fn(),
resetContentHandler: jest.fn(),
syncStepMessage: jest.fn(),
+ prunePtcTraces: jest.fn(),
clearStepMaps: mockClearStepMaps,
flushPendingDeltas: jest.fn(),
messageHandler: jest.fn(),
diff --git a/client/src/hooks/SSE/__tests__/useStepHandler.spec.ts b/client/src/hooks/SSE/__tests__/useStepHandler.spec.ts
index 81f0986dc9..310f9bd0d8 100644
--- a/client/src/hooks/SSE/__tests__/useStepHandler.spec.ts
+++ b/client/src/hooks/SSE/__tests__/useStepHandler.spec.ts
@@ -15,8 +15,11 @@ import type {
TConversation,
TMessage,
SubagentUpdateEvent,
+ PtcToolCallEvent,
Agents,
} from 'librechat-data-provider';
+import type { PtcTrace, PtcTraceEntry } from '~/store/ptc';
+import { ptcTraceByToolCallId, ptcTraceKey, PTC_TRACE_MAX_ENTRIES } from '~/store/ptc';
import { subagentProgressByToolCallId, subagentProgressKey } from '~/store/subagents';
import { resolveAskUserQuestionPart } from '~/utils/approval';
import useStepHandler from '~/hooks/SSE/useStepHandler';
@@ -3913,4 +3916,315 @@ describe('useStepHandler', () => {
expect(response?.content).toHaveLength(3);
});
});
+
+ describe('PTC inner tool-call trace', () => {
+ const renderWithTraceReader = () => {
+ const hookResult = renderHook(
+ () => {
+ const stepHandler = useStepHandler(createHookParams());
+ const read = useRecoilCallback(
+ ({ snapshot }) =>
+ (messageId: string, toolCallId: string): PtcTrace =>
+ snapshot
+ .getLoadable(ptcTraceByToolCallId(ptcTraceKey(messageId, toolCallId)))
+ .valueOrThrow(),
+ [],
+ );
+ return { ...stepHandler, read };
+ },
+ { wrapper: RecoilRoot },
+ );
+ return {
+ result: hookResult.result,
+ getEntries: (toolCallId: string, messageId = 'response-msg-1'): PtcTraceEntry[] =>
+ (
+ hookResult.result.current as unknown as { read: (m: string, id: string) => PtcTrace }
+ ).read(messageId, toolCallId).entries,
+ getTrace: (toolCallId: string, messageId = 'response-msg-1'): PtcTrace =>
+ (
+ hookResult.result.current as unknown as { read: (m: string, id: string) => PtcTrace }
+ ).read(messageId, toolCallId),
+ };
+ };
+
+ const ptcEvent = (overrides: Partial): PtcToolCallEvent => ({
+ tool_call_id: 'call_ptc',
+ call_id: 'call_ptc:0',
+ name: 'read_file',
+ status: 'running',
+ runId: 'response-msg-1',
+ ...overrides,
+ });
+
+ it('appends a row for each inner call the program starts', () => {
+ const { result, getEntries } = renderWithTraceReader();
+ const submission = createSubmission();
+
+ act(() => {
+ result.current.stepHandler(
+ {
+ event: StepEvents.ON_PTC_TOOL_CALL,
+ data: ptcEvent({ call_id: 'call_ptc:0', args: 'path=a.ts' }),
+ },
+ submission,
+ );
+ result.current.stepHandler(
+ {
+ event: StepEvents.ON_PTC_TOOL_CALL,
+ data: ptcEvent({ call_id: 'call_ptc:1', name: 'write_file', args: 'path=b.ts' }),
+ },
+ submission,
+ );
+ });
+
+ expect(getEntries('call_ptc')).toEqual([
+ { callId: 'call_ptc:0', name: 'read_file', status: 'running', args: 'path=a.ts' },
+ { callId: 'call_ptc:1', name: 'write_file', status: 'running', args: 'path=b.ts' },
+ ]);
+ });
+
+ it('settles a row in place instead of appending a duplicate', () => {
+ const { result, getEntries } = renderWithTraceReader();
+ const submission = createSubmission();
+
+ act(() => {
+ result.current.stepHandler(
+ { event: StepEvents.ON_PTC_TOOL_CALL, data: ptcEvent({ args: 'path=a.ts' }) },
+ submission,
+ );
+ result.current.stepHandler(
+ {
+ event: StepEvents.ON_PTC_TOOL_CALL,
+ data: ptcEvent({ status: 'success', durationMs: 1200 }),
+ },
+ submission,
+ );
+ });
+
+ expect(getEntries('call_ptc')).toEqual([
+ {
+ callId: 'call_ptc:0',
+ name: 'read_file',
+ status: 'success',
+ args: 'path=a.ts',
+ durationMs: 1200,
+ },
+ ]);
+ });
+
+ it('keeps each PTC call trace under its own tool call id', () => {
+ const { result, getEntries } = renderWithTraceReader();
+ const submission = createSubmission();
+
+ act(() => {
+ result.current.stepHandler(
+ { event: StepEvents.ON_PTC_TOOL_CALL, data: ptcEvent({}) },
+ submission,
+ );
+ result.current.stepHandler(
+ {
+ event: StepEvents.ON_PTC_TOOL_CALL,
+ data: ptcEvent({ tool_call_id: 'call_other', call_id: 'call_other:0' }),
+ },
+ submission,
+ );
+ });
+
+ expect(getEntries('call_ptc')).toHaveLength(1);
+ expect(getEntries('call_other')).toHaveLength(1);
+ });
+
+ it('drops an envelope with no call identity rather than seeding a blank row', () => {
+ const { result, getEntries } = renderWithTraceReader();
+ const submission = createSubmission();
+
+ act(() => {
+ result.current.stepHandler(
+ { event: StepEvents.ON_PTC_TOOL_CALL, data: ptcEvent({ call_id: '' }) },
+ submission,
+ );
+ });
+
+ expect(getEntries('call_ptc')).toEqual([]);
+ });
+
+ it('keeps a reused tool_call_id isolated across parent messages', () => {
+ const { result, getEntries } = renderWithTraceReader();
+ const submission = createSubmission();
+
+ act(() => {
+ result.current.stepHandler(
+ { event: StepEvents.ON_PTC_TOOL_CALL, data: ptcEvent({ name: 'read_file' }) },
+ submission,
+ );
+ result.current.stepHandler(
+ {
+ event: StepEvents.ON_PTC_TOOL_CALL,
+ data: ptcEvent({ name: 'write_file', runId: 'response-msg-2' }),
+ },
+ submission,
+ );
+ });
+
+ expect(getEntries('call_ptc').map((e) => e.name)).toEqual(['read_file']);
+ expect(getEntries('call_ptc', 'response-msg-2').map((e) => e.name)).toEqual(['write_file']);
+ });
+
+ it('drops an envelope that cannot be scoped to a parent message', () => {
+ const { result, getEntries } = renderWithTraceReader();
+ const submission = createSubmission();
+
+ act(() => {
+ result.current.stepHandler(
+ { event: StepEvents.ON_PTC_TOOL_CALL, data: ptcEvent({ runId: undefined }) },
+ submission,
+ );
+ });
+
+ expect(getEntries('call_ptc')).toEqual([]);
+ });
+
+ it('marks rows still running across a resume gap and keeps settled ones', () => {
+ const { result, getEntries } = renderWithTraceReader();
+ const submission = createSubmission();
+
+ act(() => {
+ /** Real order: each row opens as `running` before it settles. */
+ result.current.stepHandler(
+ { event: StepEvents.ON_PTC_TOOL_CALL, data: ptcEvent({ call_id: 'call_ptc:0' }) },
+ submission,
+ );
+ result.current.stepHandler(
+ {
+ event: StepEvents.ON_PTC_TOOL_CALL,
+ data: ptcEvent({ call_id: 'call_ptc:0', status: 'success', durationMs: 900 }),
+ },
+ submission,
+ );
+ result.current.stepHandler(
+ {
+ event: StepEvents.ON_PTC_TOOL_CALL,
+ data: ptcEvent({ call_id: 'call_ptc:1', name: 'write_file' }),
+ },
+ submission,
+ );
+ });
+ expect(getEntries('call_ptc')).toHaveLength(2);
+
+ act(() => {
+ (result.current as unknown as { prunePtcTraces: () => void }).prunePtcTraces();
+ });
+
+ expect(getEntries('call_ptc').map((e) => [e.callId, e.status])).toEqual([
+ ['call_ptc:0', 'success'],
+ ['call_ptc:1', 'interrupted'],
+ ]);
+ });
+
+ it('lets a call still running across the gap settle onto its marked row', () => {
+ const { result, getEntries } = renderWithTraceReader();
+ const submission = createSubmission();
+
+ act(() => {
+ result.current.stepHandler(
+ {
+ event: StepEvents.ON_PTC_TOOL_CALL,
+ data: ptcEvent({ call_id: 'call_ptc:0', name: 'write_file' }),
+ },
+ submission,
+ );
+ });
+
+ act(() => {
+ (result.current as unknown as { prunePtcTraces: () => void }).prunePtcTraces();
+ });
+ expect(getEntries('call_ptc').map((e) => e.status)).toEqual(['interrupted']);
+
+ /** The call outlived the disconnect, so its terminal event arrives on
+ * the restored live stream and must land on the row it opened. */
+ act(() => {
+ result.current.stepHandler(
+ {
+ event: StepEvents.ON_PTC_TOOL_CALL,
+ data: ptcEvent({
+ call_id: 'call_ptc:0',
+ name: 'write_file',
+ status: 'success',
+ durationMs: 1400,
+ }),
+ },
+ submission,
+ );
+ });
+
+ expect(getEntries('call_ptc')).toEqual([
+ expect.objectContaining({ callId: 'call_ptc:0', status: 'success', durationMs: 1400 }),
+ ]);
+ });
+
+ it('caps the retained rows and reports how many it dropped', () => {
+ const { result, getTrace } = renderWithTraceReader();
+ const submission = createSubmission();
+ const total = PTC_TRACE_MAX_ENTRIES + 25;
+
+ act(() => {
+ for (let i = 0; i < total; i++) {
+ result.current.stepHandler(
+ { event: StepEvents.ON_PTC_TOOL_CALL, data: ptcEvent({ call_id: `call_ptc:${i}` }) },
+ submission,
+ );
+ }
+ });
+
+ const trace = getTrace('call_ptc');
+ expect(trace.entries).toHaveLength(PTC_TRACE_MAX_ENTRIES);
+ expect(trace.dropped).toBe(25);
+ /** The tail is retained, so the newest call is still visible. */
+ expect(trace.entries.at(-1)?.callId).toBe(`call_ptc:${total - 1}`);
+ });
+
+ it('ignores a settle whose row the cap already evicted', () => {
+ const { result, getTrace } = renderWithTraceReader();
+ const submission = createSubmission();
+
+ act(() => {
+ for (let i = 0; i < PTC_TRACE_MAX_ENTRIES + 5; i++) {
+ result.current.stepHandler(
+ { event: StepEvents.ON_PTC_TOOL_CALL, data: ptcEvent({ call_id: `call_ptc:${i}` }) },
+ submission,
+ );
+ }
+ result.current.stepHandler(
+ {
+ event: StepEvents.ON_PTC_TOOL_CALL,
+ data: ptcEvent({ call_id: 'call_ptc:0', status: 'success', durationMs: 10 }),
+ },
+ submission,
+ );
+ });
+
+ const trace = getTrace('call_ptc');
+ expect(trace.entries).toHaveLength(PTC_TRACE_MAX_ENTRIES);
+ expect(trace.entries.some((e) => e.callId === 'call_ptc:0')).toBe(false);
+ });
+
+ it('releases the trace atoms on reset', () => {
+ const { result, getEntries } = renderWithTraceReader();
+ const submission = createSubmission();
+
+ act(() => {
+ result.current.stepHandler(
+ { event: StepEvents.ON_PTC_TOOL_CALL, data: ptcEvent({}) },
+ submission,
+ );
+ });
+ expect(getEntries('call_ptc')).toHaveLength(1);
+
+ act(() => {
+ (result.current as unknown as { resetPtcAtoms: () => void }).resetPtcAtoms();
+ });
+
+ expect(getEntries('call_ptc')).toEqual([]);
+ });
+ });
});
diff --git a/client/src/hooks/SSE/useEventHandlers.ts b/client/src/hooks/SSE/useEventHandlers.ts
index 570eafbcb5..d1b7d4f247 100644
--- a/client/src/hooks/SSE/useEventHandlers.ts
+++ b/client/src/hooks/SSE/useEventHandlers.ts
@@ -375,6 +375,8 @@ export default function useEventHandlers({
stepHandler,
clearStepMaps,
resetSubagentAtoms,
+ resetPtcAtoms,
+ prunePtcTraces,
syncStepMessage,
cancelPendingDeltaFlush,
flushPendingDeltas,
@@ -420,8 +422,12 @@ export default function useEventHandlers({
shouldResetSubagentAtomsOnConversationChange(previous, paramId, preserveNewConversationId)
) {
resetSubagentAtoms();
+ /** PTC traces are live-only for the same reason and share the boundary:
+ * keep them through a run so a finished program stays auditable, drop
+ * them when the conversation changes. */
+ resetPtcAtoms();
}
- }, [paramId, resetSubagentAtoms]);
+ }, [paramId, resetSubagentAtoms, resetPtcAtoms]);
/** Final cleanup on component unmount. `useStepHandler` keeps the
* set of known atom keys in a ref; when the hook unmounts (user
@@ -432,8 +438,9 @@ export default function useEventHandlers({
useEffect(
() => () => {
resetSubagentAtoms();
+ resetPtcAtoms();
},
- [resetSubagentAtoms],
+ [resetSubagentAtoms, resetPtcAtoms],
);
const messageHandler = useCallback(
@@ -1205,6 +1212,7 @@ export default function useEventHandlers({
createdHandler,
titleHandler,
syncStepMessage,
+ prunePtcTraces,
cancelPendingDeltaFlush,
flushPendingDeltas,
attachmentHandler,
diff --git a/client/src/hooks/SSE/useResumableSSE.ts b/client/src/hooks/SSE/useResumableSSE.ts
index 613a86e48c..64aa5bda93 100644
--- a/client/src/hooks/SSE/useResumableSSE.ts
+++ b/client/src/hooks/SSE/useResumableSSE.ts
@@ -1137,6 +1137,7 @@ export default function useResumableSSE(
createdHandler,
titleHandler,
syncStepMessage,
+ prunePtcTraces,
attachmentHandler,
resetContentHandler,
flushPendingDeltas,
@@ -2126,6 +2127,14 @@ export default function useResumableSSE(
titleHandler(data.resumeState.titleEvent);
}
+ /** PTC inner calls are not content parts, so the resume snapshot
+ * can't rebuild them and a settling event lost in this gap is
+ * never replayed. Mark rows still `running` as interrupted rather
+ * than leave a spinner that never resolves — and rather than drop
+ * them, since a call still executing across the reconnect settles
+ * normally on the restored stream and updates its row in place. */
+ prunePtcTraces();
+
if (data.resumeState?.replayEvents?.length > 0) {
logger.log(
'ResumableSSE',
@@ -3474,6 +3483,7 @@ export default function useResumableSSE(
contentHandler,
resetContentHandler,
syncStepMessage,
+ prunePtcTraces,
clearStepMaps,
messageHandler,
errorHandler,
diff --git a/client/src/hooks/SSE/useStepHandler.ts b/client/src/hooks/SSE/useStepHandler.ts
index 890217eacb..934fad74a0 100644
--- a/client/src/hooks/SSE/useStepHandler.ts
+++ b/client/src/hooks/SSE/useStepHandler.ts
@@ -19,6 +19,7 @@ import type {
TMessageContentParts,
SubagentUpdateEvent,
SandboxStartingEvent,
+ PtcToolCallEvent,
} from 'librechat-data-provider';
import type { SetterOrUpdater } from 'recoil';
import type { AnnounceOptions } from '~/common';
@@ -29,9 +30,12 @@ import {
registerSubagentProgressKey,
subagentParentStreamOpenByToolCallId,
subagentProgressByToolCallId,
- subagentProgressKey,
takeRegisteredSubagentProgressKeys,
sandboxStartingByToolCallId,
+ ptcTraceByToolCallId,
+ PTC_TRACE_MAX_ENTRIES,
+ subagentProgressKey,
+ ptcTraceKey,
} from '~/store';
import { isAskUserQuestionPart, isAnsweredAskUserQuestionPart } from '~/utils/approval';
import { MESSAGE_UPDATE_INTERVAL } from '~/common';
@@ -63,7 +67,8 @@ type TStepEvent =
| { event: StepEvents.ON_SUMMARIZE_DELTA; data: Agents.SummarizeDeltaEvent }
| { event: StepEvents.ON_SUMMARIZE_COMPLETE; data: Agents.SummarizeCompleteEvent }
| { event: StepEvents.ON_SUBAGENT_UPDATE; data: SubagentUpdateEvent }
- | { event: StepEvents.ON_SANDBOX_STARTING; data: SandboxStartingEvent };
+ | { event: StepEvents.ON_SANDBOX_STARTING; data: SandboxStartingEvent }
+ | { event: StepEvents.ON_PTC_TOOL_CALL; data: PtcToolCallEvent };
type MessageDeltaUpdate = {
type: ContentTypes.TEXT;
@@ -346,6 +351,108 @@ export default function useStepHandler({
[],
);
+ /** PTC tool call ids with a live trace, so the atoms can be released. */
+ const knownPtcAtomKeys = useRef(new Set());
+
+ /**
+ * Folds one `on_ptc_tool_call` envelope into its program's trace: the
+ * `running` event appends a row, the settling event updates that row in
+ * place by `call_id`. Order follows the sandbox's dispatch order, which is
+ * what the code reads like — a round trip can settle out of order.
+ */
+ const applyPtcToolCall = useRecoilCallback(
+ ({ set }) =>
+ (event: PtcToolCallEvent, parentMessageId: string): void => {
+ const { tool_call_id: toolCallId, call_id: callId, name, status } = event;
+ /** No parent message means no occurrence to scope this to; a raw
+ * `tool_call_id` would leak the rows into whichever card reused it. */
+ if (!toolCallId || !callId || !parentMessageId) {
+ return;
+ }
+ const atomKey = ptcTraceKey(parentMessageId, toolCallId);
+ knownPtcAtomKeys.current.add(atomKey);
+ set(ptcTraceByToolCallId(atomKey), (previous) => {
+ const index = previous.entries.findIndex((entry) => entry.callId === callId);
+ const entry = {
+ callId,
+ name,
+ status,
+ ...(event.args ? { args: event.args } : {}),
+ ...(event.error ? { error: event.error } : {}),
+ ...(event.durationMs != null ? { durationMs: event.durationMs } : {}),
+ };
+
+ if (index !== -1) {
+ const next = [...previous.entries];
+ next[index] = { ...previous.entries[index], ...entry };
+ return { entries: next, dropped: previous.dropped };
+ }
+
+ /** A settle whose row is gone — evicted by the cap, or pruned across
+ * a resume gap — must not reappear at the tail out of order. */
+ if (status !== 'running') {
+ return previous;
+ }
+
+ const appended = [...previous.entries, entry];
+ const overflow = appended.length - PTC_TRACE_MAX_ENTRIES;
+ if (overflow <= 0) {
+ return { entries: appended, dropped: previous.dropped };
+ }
+ return {
+ entries: appended.slice(overflow),
+ dropped: previous.dropped + overflow,
+ };
+ });
+ },
+ [],
+ );
+
+ const resetPtcAtoms = useRecoilCallback(
+ ({ reset }) =>
+ (): void => {
+ for (const atomKey of knownPtcAtomKeys.current) {
+ reset(ptcTraceByToolCallId(atomKey));
+ }
+ knownPtcAtomKeys.current.clear();
+ },
+ [],
+ );
+
+ /**
+ * Settles rows still marked `running` after a stream gap as `interrupted`.
+ * Inner calls carry no durable state — they are not content parts, so the
+ * resume snapshot cannot rebuild them and a settling event lost in the gap
+ * is never replayed — which means such a row would otherwise spin forever.
+ *
+ * Marked, not removed: a call can also still be executing across the
+ * reconnect, and its settling event then arrives normally on the restored
+ * live stream. That event updates this row in place, so the call reports its
+ * real outcome. Deleting the row would strand it — a settle whose row is
+ * gone is dropped rather than re-appended out of order — and the call would
+ * vanish from the trace despite having run. Settled rows are untouched.
+ */
+ const prunePtcTraces = useRecoilCallback(
+ ({ set }) =>
+ (): void => {
+ for (const atomKey of knownPtcAtomKeys.current) {
+ set(ptcTraceByToolCallId(atomKey), (previous) =>
+ previous.entries.some((entry) => entry.status === 'running')
+ ? {
+ ...previous,
+ entries: previous.entries.map((entry) =>
+ entry.status === 'running'
+ ? { ...entry, status: 'interrupted' as const }
+ : entry,
+ ),
+ }
+ : previous,
+ );
+ }
+ },
+ [],
+ );
+
/**
* Calculate content index for a run step.
*
@@ -1258,6 +1365,14 @@ export default function useStepHandler({
);
} else if (stepEvent.event === StepEvents.ON_SANDBOX_STARTING) {
setSandboxStarting(stepEvent.data.tool_call_id);
+ } else if (stepEvent.event === StepEvents.ON_PTC_TOOL_CALL) {
+ /** `runId` is the response message id (the run configurable's
+ * `run_id`), the same correlation the subagent path uses. */
+ let responseMessageId = stepEvent.data.runId ?? '';
+ if (responseMessageId === Constants.USE_PRELIM_RESPONSE_MESSAGE_ID) {
+ responseMessageId = submission?.initialResponse?.messageId ?? '';
+ }
+ applyPtcToolCall(stepEvent.data, responseMessageId);
} else if (stepEvent.event === StepEvents.ON_SUBAGENT_UPDATE) {
let responseMessageId = stepEvent.data.runId;
if (responseMessageId === Constants.USE_PRELIM_RESPONSE_MESSAGE_ID) {
@@ -1369,6 +1484,7 @@ export default function useStepHandler({
applySubagentUpdate,
setSandboxStarting,
clearSandboxStarting,
+ applyPtcToolCall,
onSkillAuthoringComplete,
],
);
@@ -1459,6 +1575,8 @@ export default function useStepHandler({
stepHandler,
clearStepMaps,
resetSubagentAtoms,
+ resetPtcAtoms,
+ prunePtcTraces,
syncStepMessage,
cancelPendingDeltaFlush,
flushPendingDeltas,
diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json
index 3632505a76..4840932437 100644
--- a/client/src/locales/en/translation.json
+++ b/client/src/locales/en/translation.json
@@ -1742,6 +1742,12 @@
"com_ui_provider": "Provider",
"com_ui_provider_api_keys_description": "Manage API keys for endpoints configured to use a user-provided key.",
"com_ui_provider_api_keys_not_set": "No key set",
+ "com_ui_ptc_trace_done": "done",
+ "com_ui_ptc_trace_earlier": "+{{count}} earlier calls",
+ "com_ui_ptc_trace_failed": "failed",
+ "com_ui_ptc_trace_interrupted": "interrupted",
+ "com_ui_ptc_trace_running": "running",
+ "com_ui_ptc_trace_title": "Tool calls",
"com_ui_quality": "Quality",
"com_ui_question_failed": "Question wasn't shown",
"com_ui_question_failed_description": "The agent couldn't show this question and may retry automatically.",
diff --git a/client/src/store/index.ts b/client/src/store/index.ts
index 391d0ee481..4d0eb715e0 100644
--- a/client/src/store/index.ts
+++ b/client/src/store/index.ts
@@ -17,6 +17,7 @@ export * from './mcp';
export * from './favorites';
export * from './subagents';
export * from './sandbox';
+export * from './ptc';
export * from './usage';
export * from './steer';
diff --git a/client/src/store/ptc.ts b/client/src/store/ptc.ts
new file mode 100644
index 0000000000..32332f1860
--- /dev/null
+++ b/client/src/store/ptc.ts
@@ -0,0 +1,64 @@
+import { atomFamily } from 'recoil';
+import type { PtcToolCallStatus } from 'librechat-data-provider';
+
+/**
+ * A row's outcome. Widens the wire status with `interrupted`, which the
+ * backend never sends: it is what the client concludes locally about a call
+ * that was still running when a stream gap swallowed its settling event.
+ */
+export type PtcTraceStatus = PtcToolCallStatus | 'interrupted';
+
+/** One tool call a programmatic (PTC) program made from inside the sandbox. */
+export interface PtcTraceEntry {
+ /** Stable id from the backend; the settle event updates this row in place. */
+ callId: string;
+ /** Inner tool id, e.g. `search_code_mcp_github`. */
+ name: string;
+ status: PtcTraceStatus;
+ /** `key=value` preview of the call's input. */
+ args?: string;
+ /** Truncated failure message on a failed call. */
+ error?: string;
+ durationMs?: number;
+}
+
+/**
+ * Stable identity for one PTC invocation in its parent message. Providers
+ * reuse `tool_call_id` across turns and agents, so the raw id is not
+ * sufficient identity for live progress — the same rule `subagentProgressKey`
+ * exists for. Without the message scope, a later program's rows would land in
+ * an earlier card that happened to share `call_0`.
+ */
+export const ptcTraceKey = (parentMessageId: string, toolCallId: string) =>
+ `${parentMessageId}\u0000${toolCallId}`;
+
+/**
+ * Rolling cap on retained rows. A program looping over a large collection can
+ * make thousands of inner calls; without a bound, every event would copy an
+ * ever-growing array and the card would render a row per call.
+ */
+export const PTC_TRACE_MAX_ENTRIES = 100;
+
+/** One PTC program's trace: the retained tail plus what the cap discarded. */
+export interface PtcTrace {
+ entries: PtcTraceEntry[];
+ /** Rows evicted by {@link PTC_TRACE_MAX_ENTRIES}, so the cap is never silent. */
+ dropped: number;
+}
+
+/** Shared empty value — one reference, so untouched atoms compare equal. */
+export const EMPTY_PTC_TRACE: PtcTrace = { entries: [], dropped: 0 };
+
+/**
+ * Live trace of the inner tool calls made by one PTC run step, in the order
+ * the sandbox started them (`on_ptc_tool_call` SSE events). Keyed by
+ * {@link ptcTraceKey} — one concrete invocation in one message.
+ *
+ * Session-scoped like the subagent ticker: inner calls open no run step, so
+ * nothing persists them on the message. Cleared on conversation switch rather
+ * than at run boundaries, so a finished program's trace stays readable.
+ */
+export const ptcTraceByToolCallId = atomFamily({
+ key: 'ptcTraceByToolCallId',
+ default: EMPTY_PTC_TRACE,
+});
diff --git a/packages/api/src/agents/handlers.spec.ts b/packages/api/src/agents/handlers.spec.ts
index 12283aa1e1..86d6c50821 100644
--- a/packages/api/src/agents/handlers.spec.ts
+++ b/packages/api/src/agents/handlers.spec.ts
@@ -10,6 +10,7 @@ import type {
ToolExecuteResult,
ToolCallRequest,
} from '@librechat/agents';
+import type { PtcToolCallEvent } from 'librechat-data-provider';
import type { CodeExecutionContext } from './execution';
import { createToolExecuteHandler, ToolExecuteOptions } from './handlers';
import { markSandboxReady } from './prewarm';
@@ -976,6 +977,85 @@ describe('createToolExecuteHandler', () => {
expect(capturedConfigs[0].disallowedToolDefs).toEqual([]);
expect(capturedConfigs[0].toolMap).toEqual(new Map());
});
+
+ it('instruments the PTC tool map so inner calls report progress', async () => {
+ const capturedConfigs: Record[] = [];
+ const ptcTool = createMockTool(Constants.PROGRAMMATIC_TOOL_CALLING, capturedConfigs);
+ /** `allowed_callers` must admit code execution, or the caller-capability
+ * filter drops the tool before the trace ever sees it. */
+ const toolRegistry = new Map([
+ ['custom_tool', { name: 'custom_tool', allowed_callers: ['code_execution'] }],
+ ]);
+ const ptcToolMap = new Map([['custom_tool', createMockTool('custom_tool', [])]]);
+ const loadTools: ToolExecuteOptions['loadTools'] = jest.fn(async () => ({
+ loadedTools: [ptcTool] as never[],
+ configurable: { toolRegistry, ptcToolMap },
+ }));
+ const events: PtcToolCallEvent[] = [];
+ const handler = createToolExecuteHandler({
+ loadTools,
+ emitPtcProgress: (event) => events.push(event),
+ });
+
+ await invokeHandler(handler, [
+ {
+ id: 'call_ptc',
+ name: Constants.PROGRAMMATIC_TOOL_CALLING,
+ args: { code: 'custom_tool "{}"' },
+ },
+ ]);
+
+ const injectedMap = capturedConfigs[0].toolMap as Map<
+ string,
+ { name: string; invoke: (input: unknown, config?: unknown) => Promise }
+ >;
+ expect(injectedMap).not.toBe(ptcToolMap);
+ expect(injectedMap.get('custom_tool')?.name).toBe('custom_tool');
+
+ await injectedMap
+ .get('custom_tool')
+ ?.invoke({ path: 'a.ts' }, { metadata: { [Constants.PROGRAMMATIC_TOOL_CALLING]: true } });
+
+ expect(events.map((event) => event.status)).toEqual(['running', 'success']);
+ expect(events[0]).toMatchObject({
+ tool_call_id: 'call_ptc',
+ name: 'custom_tool',
+ args: 'path=a.ts',
+ });
+ });
+
+ it('instruments only the tools the caller-capability filter admits', async () => {
+ const capturedConfigs: Record[] = [];
+ const ptcTool = createMockTool(Constants.PROGRAMMATIC_TOOL_CALLING, capturedConfigs);
+ const toolRegistry = new Map([
+ ['code_tool', { name: 'code_tool', allowed_callers: ['code_execution'] }],
+ ['direct_tool', { name: 'direct_tool', allowed_callers: ['direct'] }],
+ ]);
+ const ptcToolMap = new Map([
+ ['code_tool', createMockTool('code_tool', [])],
+ ['direct_tool', createMockTool('direct_tool', [])],
+ ]);
+ const loadTools: ToolExecuteOptions['loadTools'] = jest.fn(async () => ({
+ loadedTools: [ptcTool] as never[],
+ configurable: { toolRegistry, ptcToolMap },
+ }));
+ const handler = createToolExecuteHandler({
+ loadTools,
+ emitPtcProgress: () => {},
+ });
+
+ await invokeHandler(handler, [
+ {
+ id: 'call_ptc',
+ name: Constants.PROGRAMMATIC_TOOL_CALLING,
+ args: { code: 'code_tool "{}"' },
+ },
+ ]);
+
+ /** Tracing must not widen what the sandbox can reach. */
+ const injectedMap = capturedConfigs[0].toolMap as Map;
+ expect([...injectedMap.keys()]).toEqual(['code_tool']);
+ });
});
describe('host file authoring collisions', () => {
diff --git a/packages/api/src/agents/handlers.ts b/packages/api/src/agents/handlers.ts
index ce4280694f..4d5cf8428c 100644
--- a/packages/api/src/agents/handlers.ts
+++ b/packages/api/src/agents/handlers.ts
@@ -15,8 +15,8 @@ import type {
CallerCapabilityProjectionSnapshot,
} from '@librechat/agents';
import type { StructuredToolInterface } from '@librechat/agents/langchain/tools';
+import type { CodeEnvRef, PtcToolCallEvent } from 'librechat-data-provider';
import type { ValidationIssue } from '@librechat/data-schemas';
-import type { CodeEnvRef } from 'librechat-data-provider';
import type { SkillFileRecord, PrimeSkillFilesResult } from './skillFiles';
import type { CodeExecutionContext } from './execution';
import type { TextContentFragment } from '~/protection';
@@ -72,6 +72,7 @@ import { buildSkillPrimeMessage, SKILL_FILE_PREFIX } from './skills';
import { parseFrontmatter } from '../skills/import';
import { cleanCodeToolOutput } from './cleanup';
import { primeSkillFiles } from './skillFiles';
+import { instrumentPtcToolMap } from './ptc';
import { markSandboxReady } from './prewarm';
export interface ToolEndCallbackData {
@@ -136,6 +137,13 @@ export interface ToolExecuteOptions {
}) => Promise<{ attachments?: unknown[] } | null>;
/** Emits an `attachment` SSE event on the current request's live stream. */
emitAttachment?: (attachment: unknown) => void;
+ /**
+ * Emits an `on_ptc_tool_call` SSE event for one inner tool invocation made
+ * by a programmatic tool-calling program. Absent on transports that don't
+ * carry the LibreChat step stream (Open Responses), which simply skips the
+ * instrumentation.
+ */
+ emitPtcProgress?: (event: PtcToolCallEvent) => void;
/**
* Loads a skill by name with ACL constraint (returns full body for injection).
*
@@ -742,6 +750,35 @@ function filteredToolArgumentsResult(
}
}
+/**
+ * Inner tool names the `name` PII policy would block. `filteredToolArgumentsResult`
+ * inspects `tc.name` for direct calls, but inner calls bypass it entirely — and
+ * the trace event carries the name unconditionally, so without this the trace
+ * becomes the disclosure path the policy exists to close. The eligible map holds
+ * a handful of names, each inspected once per PTC call.
+ */
+function collectFilteredPtcToolNames(
+ names: Iterable,
+ req: ServerRequest | undefined,
+): ReadonlySet | undefined {
+ const filters = req?.config?.filters;
+ if (filters == null || !hasActivePiiFields(filters.toolArguments?.pii, ['name'])) {
+ return undefined;
+ }
+ const blocked = new Set();
+ for (const name of names) {
+ try {
+ if (inspectContent(extractToolArgumentContent({ name }), { filters }) != null) {
+ blocked.add(name);
+ }
+ } catch {
+ /* An un-inspectable name is treated as blocked: fail closed. */
+ blocked.add(name);
+ }
+ }
+ return blocked.size > 0 ? blocked : undefined;
+}
+
function filteredToolOutputResult(
tc: ToolCallRequest,
req: ServerRequest | undefined,
@@ -4150,8 +4187,14 @@ function buildToolCallConfig(
}
export function createToolExecuteHandler(options: ToolExecuteOptions): EventHandler {
- const { loadTools, toolEndCallback, persistBackgroundCodeResult, emitAttachment, subagentTasks } =
- options;
+ const {
+ loadTools,
+ toolEndCallback,
+ persistBackgroundCodeResult,
+ emitAttachment,
+ emitPtcProgress,
+ subagentTasks,
+ } = options;
return {
handle: async (_event: string, data: ToolExecuteBatchRequest) => {
@@ -5007,9 +5050,42 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand
toolCallConfig.toolDefs = toolDefs;
toolCallConfig.disallowedToolDefs = disallowedToolDefs;
const eligibleNames = new Set(toolDefs.map((toolDef) => toolDef.name));
- toolCallConfig.toolMap = new Map(
+ /* Instrument the ELIGIBLE map, never the raw one: the
+ * caller-capability restriction decides what the sandbox
+ * may reach, and tracing must not widen it. */
+ const eligiblePtcToolMap = new Map(
[...(ptcToolMap ?? toolMap)].filter(([name]) => eligibleNames.has(name)),
);
+ /* Inner calls produce no run step and no card of their
+ * own, so the only record of what the program did is
+ * this trace. `invoke` is the single seam every inner
+ * call passes through.
+ *
+ * They also never reach `filteredToolArgumentsResult` —
+ * the sandbox bridge invokes them directly — so when the
+ * deployment filters tool arguments for PII, the trace
+ * must not put their values on the wire. */
+ const ptcReq = mergedConfigurable?.req as ServerRequest | undefined;
+ const ptcArgumentPii = ptcReq?.config?.filters?.toolArguments?.pii;
+ toolCallConfig.toolMap = emitPtcProgress
+ ? instrumentPtcToolMap({
+ toolMap: eligiblePtcToolMap,
+ toolCallId: tc.id,
+ runId: (metadata as Record)?.run_id as
+ | string
+ | undefined,
+ includePreviews: !hasActivePiiFields(ptcArgumentPii, [
+ 'name',
+ 'arguments',
+ 'output',
+ ]),
+ traceExclusions: collectFilteredPtcToolNames(
+ eligiblePtcToolMap.keys(),
+ ptcReq,
+ ),
+ emit: emitPtcProgress,
+ })
+ : eligiblePtcToolMap;
}
}
diff --git a/packages/api/src/agents/index.ts b/packages/api/src/agents/index.ts
index 4e4b04eb94..9444d41bc2 100644
--- a/packages/api/src/agents/index.ts
+++ b/packages/api/src/agents/index.ts
@@ -25,6 +25,7 @@ export * from './orphans';
export * from './migration';
export * from './parameters';
export * from './prewarm';
+export * from './ptc';
export * from './openai';
export * from './transactions';
export * from './traversal';
diff --git a/packages/api/src/agents/ptc.spec.ts b/packages/api/src/agents/ptc.spec.ts
new file mode 100644
index 0000000000..3a3f04179a
--- /dev/null
+++ b/packages/api/src/agents/ptc.spec.ts
@@ -0,0 +1,247 @@
+import type { StructuredToolInterface } from '@librechat/agents/langchain/tools';
+import type { PtcToolCallEvent } from 'librechat-data-provider';
+import { instrumentPtcToolMap, summarizePtcArgs } from './ptc';
+
+/**
+ * Minimal stand-in for a loaded tool: `executeTools` in `@librechat/agents`
+ * only resolves a tool by name, reads `schema`/`mcp`, and calls `invoke` — so
+ * the wrapper must leave all of that intact.
+ */
+function createTool(
+ name: string,
+ invoke: (input: unknown, config?: unknown) => Promise,
+ extra: Record = {},
+): StructuredToolInterface {
+ return { name, invoke, ...extra } as unknown as StructuredToolInterface;
+}
+
+describe('summarizePtcArgs', () => {
+ it('renders an object input as a key=value line', () => {
+ expect(summarizePtcArgs({ query: 'librechat', limit: 5 })).toBe('query=librechat, limit=5');
+ });
+
+ it('drops empty and nullish values', () => {
+ expect(summarizePtcArgs({ path: 'a.ts', cursor: null, filter: '' })).toBe('path=a.ts');
+ });
+
+ it('collapses whitespace so a multi-line value stays one line', () => {
+ expect(summarizePtcArgs({ code: 'a\n b' })).toBe('code=a b');
+ });
+
+ it('clips a long value without dropping the keys after it', () => {
+ const summary = summarizePtcArgs({ body: 'x'.repeat(200), path: 'a.ts' });
+ expect(summary).toContain('…');
+ expect(summary).toContain('path=a.ts');
+ });
+
+ it('bounds the whole preview', () => {
+ const summary = summarizePtcArgs(
+ Object.fromEntries(Array.from({ length: 20 }, (_, i) => [`key${i}`, `value${i}`])),
+ );
+ expect(summary.length).toBeLessThanOrEqual(97);
+ });
+
+ it('stops once the preview budget is spent instead of visiting every key', () => {
+ const seen: string[] = [];
+ const probe: Record = {};
+ for (let i = 0; i < 40; i++) {
+ Object.defineProperty(probe, `key${i}`, {
+ enumerable: true,
+ get() {
+ seen.push(`key${i}`);
+ return `value${i}`;
+ },
+ });
+ }
+
+ summarizePtcArgs(probe);
+
+ expect(seen.length).toBeLessThan(40);
+ });
+
+ it('does not rewrite the whole of an oversized value to build a short preview', () => {
+ const huge = 'a b '.repeat(500_000);
+ const started = Date.now();
+ const summary = summarizePtcArgs({ content: huge, path: 'a.ts' });
+
+ expect(summary).toContain('path=a.ts');
+ expect(summary.length).toBeLessThanOrEqual(97);
+ expect(Date.now() - started).toBeLessThan(150);
+ });
+
+ it('falls back to the raw string for a non-object input', () => {
+ expect(summarizePtcArgs('ls -la')).toBe('ls -la');
+ });
+
+ it('returns an empty preview for an absent input', () => {
+ expect(summarizePtcArgs(undefined)).toBe('');
+ expect(summarizePtcArgs({})).toBe('');
+ });
+});
+
+describe('instrumentPtcToolMap', () => {
+ const collect = () => {
+ const events: PtcToolCallEvent[] = [];
+ return { events, emit: (event: PtcToolCallEvent) => events.push(event) };
+ };
+
+ it('emits a running event and a success event around an inner call', async () => {
+ const { events, emit } = collect();
+ const toolMap = new Map([['read_file', createTool('read_file', async () => 'file contents')]]);
+
+ const instrumented = instrumentPtcToolMap({ toolMap, toolCallId: 'call_1', emit });
+ const result = await instrumented.get('read_file')?.invoke({ path: 'a.ts' });
+
+ expect(result).toBe('file contents');
+ expect(events).toHaveLength(2);
+ expect(events[0]).toMatchObject({
+ tool_call_id: 'call_1',
+ name: 'read_file',
+ status: 'running',
+ args: 'path=a.ts',
+ });
+ expect(events[1]).toMatchObject({
+ tool_call_id: 'call_1',
+ call_id: events[0].call_id,
+ status: 'success',
+ });
+ expect(events[1].durationMs).toBeGreaterThanOrEqual(0);
+ });
+
+ it('reports a failed inner call and rethrows so the sandbox still sees the error', async () => {
+ const { events, emit } = collect();
+ const toolMap = new Map([
+ [
+ 'write_file',
+ createTool('write_file', async () => {
+ throw new Error('Permission denied');
+ }),
+ ],
+ ]);
+
+ const instrumented = instrumentPtcToolMap({ toolMap, toolCallId: 'call_1', emit });
+
+ await expect(instrumented.get('write_file')?.invoke({ path: '/etc/x' })).rejects.toThrow(
+ 'Permission denied',
+ );
+ expect(events[1]).toMatchObject({ status: 'error', error: 'Permission denied' });
+ });
+
+ it('gives each inner call its own id so concurrent calls do not collide', async () => {
+ const { events, emit } = collect();
+ const toolMap = new Map([['search', createTool('search', async () => 'ok')]]);
+
+ const instrumented = instrumentPtcToolMap({ toolMap, toolCallId: 'call_1', emit });
+ const search = instrumented.get('search');
+ await Promise.all([search?.invoke({ q: 'a' }), search?.invoke({ q: 'b' })]);
+
+ const startIds = events.filter((e) => e.status === 'running').map((e) => e.call_id);
+ expect(new Set(startIds).size).toBe(2);
+ });
+
+ it('passes the invoke config through untouched', async () => {
+ const { emit } = collect();
+ const seen: unknown[] = [];
+ const toolMap = new Map([
+ [
+ 'read_file',
+ createTool('read_file', async (_input, config) => {
+ seen.push(config);
+ return 'ok';
+ }),
+ ],
+ ]);
+
+ const instrumented = instrumentPtcToolMap({ toolMap, toolCallId: 'call_1', emit });
+ const config = { metadata: { run_tools_with_code: true } };
+ await instrumented.get('read_file')?.invoke({ path: 'a.ts' }, config);
+
+ expect(seen[0]).toBe(config);
+ });
+
+ it('leaves every other property readable on the wrapped tool', () => {
+ const { emit } = collect();
+ const toolMap = new Map([
+ [
+ 'search_code_mcp_github',
+ createTool('search_code_mcp_github', async () => 'ok', {
+ mcp: true,
+ schema: { type: 'object' },
+ }),
+ ],
+ ]);
+
+ const instrumented = instrumentPtcToolMap({ toolMap, toolCallId: 'call_1', emit });
+ const tool = instrumented.get('search_code_mcp_github') as StructuredToolInterface & {
+ mcp?: boolean;
+ };
+
+ expect(tool.name).toBe('search_code_mcp_github');
+ expect(tool.mcp).toBe(true);
+ expect(tool.schema).toEqual({ type: 'object' });
+ });
+
+ it('omits argument and failure previews when tool-argument filtering is on', async () => {
+ const { events, emit } = collect();
+ const toolMap = new Map([
+ [
+ 'write_file',
+ createTool('write_file', async () => {
+ throw new Error('rejected value 555-01-0000');
+ }),
+ ],
+ ]);
+
+ const instrumented = instrumentPtcToolMap({
+ toolMap,
+ toolCallId: 'call_1',
+ includePreviews: false,
+ emit,
+ });
+
+ await expect(instrumented.get('write_file')?.invoke({ ssn: '555-01-0000' })).rejects.toThrow();
+
+ /** Name, status and duration still report; nothing derived from the
+ * arguments or the failure text reaches the stream. */
+ expect(events[0].args).toBeUndefined();
+ expect(events[1].error).toBeUndefined();
+ expect(events.map((e) => e.status)).toEqual(['running', 'error']);
+ expect(events[1].durationMs).toBeGreaterThanOrEqual(0);
+ expect(JSON.stringify(events)).not.toContain('555-01-0000');
+ });
+
+ it('emits nothing at all for a tool whose name the policy filters', async () => {
+ const { events, emit } = collect();
+ const toolMap = new Map([
+ ['ok_tool', createTool('ok_tool', async () => 'ok')],
+ ['blocked_name_tool', createTool('blocked_name_tool', async () => 'ok')],
+ ]);
+
+ const instrumented = instrumentPtcToolMap({
+ toolMap,
+ toolCallId: 'call_1',
+ traceExclusions: new Set(['blocked_name_tool']),
+ emit,
+ });
+
+ /** Excluded tools still execute — only their telemetry is suppressed. */
+ await expect(instrumented.get('blocked_name_tool')?.invoke({ a: 1 })).resolves.toBe('ok');
+ await instrumented.get('ok_tool')?.invoke({ a: 1 });
+
+ expect(events.map((e) => e.name)).toEqual(['ok_tool', 'ok_tool']);
+ expect(JSON.stringify(events)).not.toContain('blocked_name_tool');
+ });
+
+ it('runs the inner call even when the emitter throws', async () => {
+ const toolMap = new Map([['read_file', createTool('read_file', async () => 'ok')]]);
+ const instrumented = instrumentPtcToolMap({
+ toolMap,
+ toolCallId: 'call_1',
+ emit: () => {
+ throw new Error('stream closed');
+ },
+ });
+
+ await expect(instrumented.get('read_file')?.invoke({ path: 'a.ts' })).resolves.toBe('ok');
+ });
+});
diff --git a/packages/api/src/agents/ptc.ts b/packages/api/src/agents/ptc.ts
new file mode 100644
index 0000000000..aaa47d274f
--- /dev/null
+++ b/packages/api/src/agents/ptc.ts
@@ -0,0 +1,200 @@
+import type { StructuredToolInterface } from '@librechat/agents/langchain/tools';
+import type { PtcToolCallEvent } from 'librechat-data-provider';
+
+/** Whole-preview budget for one inner call's arguments. */
+const ARGS_PREVIEW_MAX_CHARS = 96;
+/** Per-value budget, so one long string can't crowd out the other keys. */
+const ARGS_VALUE_MAX_CHARS = 40;
+const ERROR_PREVIEW_MAX_CHARS = 160;
+/**
+ * Collapsing whitespace can only shorten a string, so a window this many times
+ * the visible budget is always long enough to fill it. Slicing to the window
+ * before rewriting matters: this runs synchronously ahead of every inner
+ * `invoke`, and without it a multi-megabyte argument would be collapsed in
+ * full to produce a forty-character preview.
+ */
+const CLIP_OVERSCAN = 4;
+
+/** Bounded collapse-and-clip: never rewrites more of `input` than the budget
+ * can possibly need, and marks any truncation it performed. */
+const clip = (input: string, max: number): string => {
+ const window = input.length > max * CLIP_OVERSCAN ? input.slice(0, max * CLIP_OVERSCAN) : input;
+ const collapsed = window.replace(/\s+/g, ' ').trim();
+ if (collapsed.length <= max && window.length === input.length) {
+ return collapsed;
+ }
+ return `${collapsed.slice(0, max)}…`;
+};
+
+/**
+ * Collapses an inner call's input into a single `key=value, key=value` line
+ * for the CLI-style trace. Values are clipped individually and iteration stops
+ * as soon as the joined preview can no longer grow, so a call with a large
+ * body or many keys costs the same as a small one.
+ */
+export function summarizePtcArgs(input: unknown): string {
+ if (input == null) {
+ return '';
+ }
+ if (typeof input === 'string') {
+ return clip(input, ARGS_PREVIEW_MAX_CHARS);
+ }
+ if (typeof input !== 'object' || Array.isArray(input)) {
+ return clip(String(input), ARGS_PREVIEW_MAX_CHARS);
+ }
+
+ const record = input as Record;
+ const entries: string[] = [];
+ let budget = ARGS_PREVIEW_MAX_CHARS;
+ /* Keys, not entries: `Object.entries` would materialize every value before
+ * the loop starts, so the budget check below could never skip the work it
+ * exists to skip. */
+ for (const key of Object.keys(record)) {
+ if (budget <= 0) {
+ break;
+ }
+ const value = record[key];
+ if (value == null || value === '') {
+ continue;
+ }
+ /* Strings are the values that get large (file bodies, request payloads),
+ * and `clip` bounds them without touching the tail. Everything else is
+ * small enough that serializing it first is cheaper than inspecting it. */
+ const rendered = typeof value === 'string' ? value : safeStringify(value);
+ if (rendered === '') {
+ continue;
+ }
+ const entry = `${key}=${clip(rendered, ARGS_VALUE_MAX_CHARS)}`;
+ entries.push(entry);
+ budget -= entry.length + 2;
+ }
+
+ return clip(entries.join(', '), ARGS_PREVIEW_MAX_CHARS);
+}
+
+function safeStringify(value: unknown): string {
+ try {
+ return JSON.stringify(value) ?? '';
+ } catch {
+ return '';
+ }
+}
+
+export interface InstrumentPtcToolMapParams {
+ /** The tool map the PTC runner resolves inner calls against. */
+ toolMap: Map;
+ /** The PTC run step's tool call id — the card the trace renders under. */
+ toolCallId: string;
+ runId?: string;
+ /**
+ * Whether argument and failure previews may ride the stream. False when the
+ * deployment filters tool arguments for PII: inner calls never pass through
+ * `filteredToolArgumentsResult` (the sandbox bridge invokes them directly),
+ * so a preview would put values on the wire that the configured policy
+ * exists to keep off it — and a failure message routinely quotes the very
+ * argument that caused it. The trace still reports name, status and duration.
+ */
+ includePreviews?: boolean;
+ /**
+ * Inner tools whose *name* trips the deployment's PII policy. The event
+ * carries the tool name unconditionally, so a name the `name` filter would
+ * have blocked on a direct call cannot be allowed to ride the trace instead.
+ * These tools still execute — they are simply left unwrapped, so no event
+ * about them is ever emitted.
+ */
+ traceExclusions?: ReadonlySet;
+ emit: (event: PtcToolCallEvent) => void;
+}
+
+/**
+ * Wraps every tool the PTC sandbox can reach so each inner invocation reports
+ * its lifecycle on the live stream. The runner (`executeTools` in
+ * `@librechat/agents`) resolves a tool by name and calls `invoke` on it, so a
+ * `Proxy` intercepting only `invoke` is enough — `name`, `schema`, `mcp` and
+ * every other property the runner reads pass straight through to the real
+ * tool, and nothing about execution changes.
+ */
+export function instrumentPtcToolMap({
+ toolMap,
+ toolCallId,
+ runId,
+ includePreviews = true,
+ traceExclusions,
+ emit,
+}: InstrumentPtcToolMapParams): Map {
+ let sequence = 0;
+ /** Emission is telemetry: a dead stream must never fail the program. */
+ const safeEmit = (event: PtcToolCallEvent): void => {
+ try {
+ emit(event);
+ } catch {
+ /* stream closed or transport rejected — the run continues */
+ }
+ };
+
+ const instrumented = new Map();
+ for (const [name, tool] of toolMap) {
+ if (traceExclusions?.has(name)) {
+ instrumented.set(name, tool);
+ continue;
+ }
+ instrumented.set(
+ name,
+ new Proxy(tool, {
+ get(target, property) {
+ if (property !== 'invoke') {
+ /** `target` as the receiver, not the proxy: LangChain tools read
+ * private class state through their own getters. */
+ return Reflect.get(target, property, target);
+ }
+ return async (input: unknown, config?: unknown): Promise => {
+ const callId = `${toolCallId}:${sequence++}`;
+ const startedAt = Date.now();
+ safeEmit({
+ tool_call_id: toolCallId,
+ call_id: callId,
+ name,
+ status: 'running',
+ ...(includePreviews ? { args: summarizePtcArgs(input) } : {}),
+ ...(runId != null ? { runId } : {}),
+ });
+ try {
+ const result = await (
+ target.invoke as (input: unknown, config?: unknown) => Promise
+ ).call(target, input, config);
+ safeEmit({
+ tool_call_id: toolCallId,
+ call_id: callId,
+ name,
+ status: 'success',
+ durationMs: Date.now() - startedAt,
+ ...(runId != null ? { runId } : {}),
+ });
+ return result;
+ } catch (error) {
+ safeEmit({
+ tool_call_id: toolCallId,
+ call_id: callId,
+ name,
+ status: 'error',
+ ...(includePreviews
+ ? {
+ error: clip(
+ error instanceof Error ? error.message : String(error),
+ ERROR_PREVIEW_MAX_CHARS,
+ ),
+ }
+ : {}),
+ durationMs: Date.now() - startedAt,
+ ...(runId != null ? { runId } : {}),
+ });
+ throw error;
+ }
+ };
+ },
+ }) as StructuredToolInterface,
+ );
+ }
+
+ return instrumented;
+}
diff --git a/packages/data-provider/src/types/runs.ts b/packages/data-provider/src/types/runs.ts
index 69c7b18c8b..e8498bdd74 100644
--- a/packages/data-provider/src/types/runs.ts
+++ b/packages/data-provider/src/types/runs.ts
@@ -46,6 +46,7 @@ export enum StepEvents {
ON_SUMMARIZE_COMPLETE = 'on_summarize_complete',
ON_SUBAGENT_UPDATE = 'on_subagent_update',
ON_SANDBOX_STARTING = 'on_sandbox_starting',
+ ON_PTC_TOOL_CALL = 'on_ptc_tool_call',
}
/** Payload for {@link StepEvents.ON_SANDBOX_STARTING} — the stateful code
@@ -55,6 +56,32 @@ export type SandboxStartingEvent = {
runId?: string;
};
+/** Lifecycle of one tool call made from inside a programmatic (PTC) program. */
+export type PtcToolCallStatus = 'running' | 'success' | 'error';
+
+/**
+ * Payload for {@link StepEvents.ON_PTC_TOOL_CALL} — one tool invocation the
+ * sandbox made on behalf of a programmatic tool-calling program. Emitted twice
+ * per inner call (`running`, then `success` / `error`) so the PTC card can
+ * render a live trace of what the code is doing under the code itself.
+ */
+export type PtcToolCallEvent = {
+ /** The PTC run step's tool call id — the card this line belongs under. */
+ tool_call_id: string;
+ /** Stable per-program id; the settle event reuses the start event's value. */
+ call_id: string;
+ /** Inner tool id, e.g. `search_code_mcp_github`. */
+ name: string;
+ status: PtcToolCallStatus;
+ /** `key=value` preview of the call's input. Start event only. */
+ args?: string;
+ /** Truncated failure message. `error` status only. */
+ error?: string;
+ /** Wall-clock time the inner call took. Settle events only. */
+ durationMs?: number;
+ runId?: string;
+};
+
/** Token-tracking event names streamed to the client (separate from StepEvents dispatch). */
export enum UsageEvents {
ON_CONTEXT_USAGE = 'on_context_usage',