mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-23 19:57:09 +00:00
Keep Assistant tool-call leaks out of chat output
This commit is contained in:
@@ -174,7 +174,10 @@ runtime cost control, and shared AI transport surfaces.
|
||||
approval, or question blocks; if a provider emits `pulse_*` / `patrol_*`
|
||||
calls, DSML, XML/function-call envelopes, or JSON tool-call shapes as text
|
||||
content, the chat runtime must strip them before streaming, persistence, and
|
||||
frontend rendering. Completed tool rows in the drawer may show compact tool
|
||||
frontend rendering. Compacted no-whitespace internal prelude text attached
|
||||
to a leaked tool invocation is part of that same artifact and must be
|
||||
suppressed or retracted from the current stream segment instead of rendered
|
||||
as assistant prose. Completed tool rows in the drawer may show compact tool
|
||||
name, action summary, status, and an explicit details affordance, but raw
|
||||
tool input/output JSON must not render in the default transcript. Token
|
||||
accounting and other provider metadata remain runtime/accounting data, not
|
||||
|
||||
@@ -36,6 +36,15 @@ describe('stripAssistantOutputArtifacts', () => {
|
||||
expect(result.stripped).toBe(true);
|
||||
});
|
||||
|
||||
it('suppresses compacted internal prose before raw function-call leaks', () => {
|
||||
const result = stripAssistantOutputArtifacts(
|
||||
'I\'llcheckthedevicenodesinsidethecontainertoanswerthat.Letmecounttheentriesin/devandlisttheblockdevices.pulse_read(target_host="current_resource", command="lsblk")',
|
||||
);
|
||||
|
||||
expect(result.text).toBe('');
|
||||
expect(result.stripped).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves ordinary prose and unrelated function calls alone', () => {
|
||||
expect(stripAssistantOutputArtifacts('Call helper(target="x") in the example.')).toEqual({
|
||||
text: 'Call helper(target="x") in the example.',
|
||||
@@ -62,6 +71,29 @@ describe('stripAssistantOutputArtifacts', () => {
|
||||
expect(flushPendingAssistantOutputText(state)).toBe('');
|
||||
});
|
||||
|
||||
it('asks callers to replace already emitted compacted prose when a split leak completes', () => {
|
||||
const state = createAssistantOutputArtifactStreamState();
|
||||
const first =
|
||||
"I'llcheckthedevicenodesinsidethecontainertoanswerthat.Letmecounttheentriesin/devandlisttheblockdevices.";
|
||||
|
||||
expect(appendVisibleTextBeforeAssistantOutputArtifacts(state, first)).toEqual({
|
||||
text: first,
|
||||
stripped: false,
|
||||
});
|
||||
expect(
|
||||
appendVisibleTextBeforeAssistantOutputArtifacts(
|
||||
state,
|
||||
'pulse_read(target_host="current_resource", command="lsblk")',
|
||||
),
|
||||
).toEqual({
|
||||
text: '',
|
||||
stripped: true,
|
||||
previousVisibleText: first,
|
||||
replacementText: '',
|
||||
});
|
||||
expect(flushPendingAssistantOutputText(state)).toBe('');
|
||||
});
|
||||
|
||||
it('releases a held prefix when the next delta proves it is normal prose', () => {
|
||||
const state = createAssistantOutputArtifactStreamState();
|
||||
|
||||
|
||||
@@ -842,6 +842,32 @@ describe('useChat', () => {
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('retracts compacted internal prose when a split tool-call leak completes', async () => {
|
||||
const { getFireEvent } = setupWithEventCapture();
|
||||
const { value: chat, dispose } = withRoot(() => useChat({ sessionId: 's' }));
|
||||
|
||||
await chat.sendMessage('how many devices in this');
|
||||
const fire = getFireEvent();
|
||||
const compacted =
|
||||
"I'llcheckthedevicenodesinsidethecontainertoanswerthat.Letmecounttheentriesin/devandlisttheblockdevices.";
|
||||
|
||||
fire({ type: 'content', data: compacted });
|
||||
expect(chat.messages().find((m) => m.role === 'assistant')?.content).toBe(compacted);
|
||||
|
||||
fire({
|
||||
type: 'content',
|
||||
data: 'pulse_read(target_host="current_resource", command="ls /dev | wc -l")',
|
||||
});
|
||||
fire({ type: 'content', data: 'raw arguments that should stay hidden' });
|
||||
|
||||
const assistant = chat.messages().find((m) => m.role === 'assistant')!;
|
||||
expect(assistant.content).toBe('');
|
||||
expect(assistant.content).not.toContain('pulse_read');
|
||||
expect(assistant.content).not.toContain('raw arguments');
|
||||
expect(assistant.streamEvents?.filter((e) => e.type === 'content')).toEqual([]);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('resumes visible content after a governed tool boundary clears a raw leak', async () => {
|
||||
const { getFireEvent } = setupWithEventCapture();
|
||||
const { value: chat, dispose } = withRoot(() => useChat({ sessionId: 's' }));
|
||||
|
||||
@@ -49,7 +49,11 @@ export function stripAssistantOutputArtifacts(content: string): {
|
||||
if (idx < 0) {
|
||||
return { text: content, stripped: false };
|
||||
}
|
||||
return { text: content.slice(0, idx).trimEnd(), stripped: true };
|
||||
const visiblePrefix = content.slice(0, idx).trim();
|
||||
return {
|
||||
text: isCompactedToolPrelude(visiblePrefix) ? '' : visiblePrefix,
|
||||
stripped: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function appendVisibleTextBeforeAssistantOutputArtifacts(
|
||||
@@ -58,6 +62,8 @@ export function appendVisibleTextBeforeAssistantOutputArtifacts(
|
||||
): {
|
||||
text: string;
|
||||
stripped: boolean;
|
||||
previousVisibleText?: string;
|
||||
replacementText?: string;
|
||||
} {
|
||||
if (!content && !state.pendingText) {
|
||||
return { text: '', stripped: false };
|
||||
@@ -77,6 +83,17 @@ export function appendVisibleTextBeforeAssistantOutputArtifacts(
|
||||
}
|
||||
|
||||
const safeText = candidate.slice(0, idx).trimEnd();
|
||||
if (isCompactedToolPrelude(safeText)) {
|
||||
const previousVisibleText = state.visibleText;
|
||||
state.visibleText = '';
|
||||
state.pendingText = '';
|
||||
return {
|
||||
text: '',
|
||||
stripped: true,
|
||||
previousVisibleText,
|
||||
replacementText: '',
|
||||
};
|
||||
}
|
||||
const visibleDelta = safeText.length > existing.length ? safeText.slice(existing.length) : '';
|
||||
state.visibleText = safeText;
|
||||
state.pendingText = '';
|
||||
@@ -180,3 +197,23 @@ function isKnownAssistantToolNamePrefix(prefix: string): boolean {
|
||||
/^patrol_[a-zA-Z0-9_]*$/.test(prefix)
|
||||
);
|
||||
}
|
||||
|
||||
function isCompactedToolPrelude(content: string): boolean {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) return false;
|
||||
|
||||
let letters = 0;
|
||||
let whitespace = 0;
|
||||
for (const char of trimmed) {
|
||||
if (/\p{L}/u.test(char)) {
|
||||
letters += 1;
|
||||
}
|
||||
if (/\s/.test(char)) {
|
||||
whitespace += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (letters < 16) return false;
|
||||
if (whitespace === 0) return true;
|
||||
return letters >= 48 && whitespace <= 1;
|
||||
}
|
||||
|
||||
@@ -251,6 +251,53 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
return `${existing} ${content}`;
|
||||
};
|
||||
|
||||
const appendTextAfterBoundary = (existing: string, content: string): string => {
|
||||
if (!existing || !content) {
|
||||
return existing + content;
|
||||
}
|
||||
if (/\s$/.test(existing) || /^\s|^[,.;:!?)]/.test(content)) {
|
||||
return existing + content;
|
||||
}
|
||||
return `${existing} ${content}`;
|
||||
};
|
||||
|
||||
const replaceCurrentAssistantOutputSegment = (
|
||||
msg: ChatMessage,
|
||||
previousVisibleText: string,
|
||||
replacementText: string,
|
||||
): ChatMessage => {
|
||||
const events = msg.streamEvents || [];
|
||||
let boundaryIndex = -1;
|
||||
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||
if (events[i].type !== 'content' && events[i].type !== 'thinking') {
|
||||
boundaryIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const retainedEvents = [
|
||||
...events.slice(0, boundaryIndex + 1),
|
||||
...events.slice(boundaryIndex + 1).filter((evt) => evt.type !== 'content'),
|
||||
];
|
||||
const streamEvents = replacementText
|
||||
? [...retainedEvents, { type: 'content' as const, content: replacementText }]
|
||||
: retainedEvents;
|
||||
|
||||
let content = msg.content || '';
|
||||
if (previousVisibleText && content.endsWith(previousVisibleText)) {
|
||||
content = content.slice(0, -previousVisibleText.length);
|
||||
}
|
||||
content = replacementText
|
||||
? appendTextAfterBoundary(content, replacementText)
|
||||
: content.trimEnd();
|
||||
|
||||
return {
|
||||
...msg,
|
||||
content,
|
||||
streamEvents,
|
||||
};
|
||||
};
|
||||
|
||||
// Process stream events
|
||||
const extractText = (value: unknown): string => {
|
||||
if (typeof value === 'string') return value;
|
||||
@@ -444,12 +491,20 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
if (visible.stripped) {
|
||||
suppressedRawContentMessageIds.add(assistantId);
|
||||
}
|
||||
if (!visible.text) return msg;
|
||||
const baseMsg =
|
||||
visible.replacementText !== undefined
|
||||
? replaceCurrentAssistantOutputSegment(
|
||||
msg,
|
||||
visible.previousVisibleText || '',
|
||||
visible.replacementText,
|
||||
)
|
||||
: msg;
|
||||
if (!visible.text) return baseMsg;
|
||||
// Add to streamEvents for chronological display
|
||||
const updated = addStreamEvent(msg, { type: 'content', content: visible.text });
|
||||
const updated = addStreamEvent(baseMsg, { type: 'content', content: visible.text });
|
||||
return {
|
||||
...updated,
|
||||
content: appendMessageContent(msg, visible.text),
|
||||
content: appendMessageContent(baseMsg, visible.text),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package chat
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai/tools"
|
||||
)
|
||||
@@ -77,7 +78,11 @@ func cleanToolCallArtifacts(content string) string {
|
||||
}
|
||||
|
||||
if idx := toolCallArtifactIndex(content); idx >= 0 {
|
||||
content = strings.TrimSpace(content[:idx])
|
||||
prefix := strings.TrimSpace(content[:idx])
|
||||
if isCompactedToolPrelude(prefix) {
|
||||
return ""
|
||||
}
|
||||
content = prefix
|
||||
}
|
||||
|
||||
return content
|
||||
@@ -161,8 +166,20 @@ func appendVisibleContentBeforeToolLeak(
|
||||
}
|
||||
|
||||
if idx > len(existing) {
|
||||
if isCompactedToolPrelude(candidate[:idx]) {
|
||||
builder.Reset()
|
||||
if pending != nil {
|
||||
*pending = ""
|
||||
}
|
||||
return "", true
|
||||
}
|
||||
visibleDelta, _ = splitTrailingPotentialToolNamePrefix(candidate[len(existing):idx])
|
||||
builder.WriteString(visibleDelta)
|
||||
} else if isCompactedToolPrelude(candidate[:idx]) {
|
||||
builder.Reset()
|
||||
if pending != nil {
|
||||
*pending = ""
|
||||
}
|
||||
}
|
||||
return visibleDelta, true
|
||||
}
|
||||
@@ -203,6 +220,32 @@ func splitTrailingPotentialToolNamePrefix(content string) (visible string, held
|
||||
return content, ""
|
||||
}
|
||||
|
||||
func isCompactedToolPrelude(content string) bool {
|
||||
trimmed := strings.TrimSpace(content)
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
letters := 0
|
||||
whitespace := 0
|
||||
for _, ch := range trimmed {
|
||||
if unicode.IsLetter(ch) {
|
||||
letters++
|
||||
}
|
||||
if unicode.IsSpace(ch) {
|
||||
whitespace++
|
||||
}
|
||||
}
|
||||
|
||||
if letters < 16 {
|
||||
return false
|
||||
}
|
||||
if whitespace == 0 {
|
||||
return true
|
||||
}
|
||||
return letters >= 48 && whitespace <= 1
|
||||
}
|
||||
|
||||
// findJSONToolCallLeak returns the byte offset to strip from when a plain-JSON
|
||||
// tool-call leak is found, or -1 if none. A match must (a) sit at start of
|
||||
// content or after a newline, (b) be a JSON object whose first key is "name",
|
||||
|
||||
@@ -71,6 +71,11 @@ func TestCleanToolCallArtifacts(t *testing.T) {
|
||||
"I will check that now. pulse_read(target_host=\"current_resource\", command=\"lsblk\")",
|
||||
"I will check that now.",
|
||||
},
|
||||
{
|
||||
"plain function leak with compacted internal prelude",
|
||||
"I'llcheckthedevicenodesinsidethecontainertoanswerthat.Letmecounttheentriesin/devandlisttheblockdevices.pulse_read(target_host=\"current_resource\", command=\"lsblk\")",
|
||||
"",
|
||||
},
|
||||
{
|
||||
"plain function leak only no prose",
|
||||
"pulse_read(target_host=\"current_resource\", command=\"lsblk\")",
|
||||
@@ -189,3 +194,46 @@ func TestAppendVisibleContentBeforeToolLeak(t *testing.T) {
|
||||
t.Fatalf("builder should not append leaked call suffix, got builder=%q pending=%q", builder.String(), pending)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendVisibleContentBeforeToolLeak_DropsCompactedPrelude(t *testing.T) {
|
||||
var builder strings.Builder
|
||||
var pending string
|
||||
|
||||
delta, leakFound := appendVisibleContentBeforeToolLeak(
|
||||
&builder,
|
||||
&pending,
|
||||
"I'llcheckthedevicenodesinsidethecontainertoanswerthat.Letmecounttheentriesin/devandlisttheblockdevices.pulse_read(target_host=\"current_resource\", command=\"lsblk\")",
|
||||
)
|
||||
if !leakFound {
|
||||
t.Fatal("expected compacted raw function call to be detected")
|
||||
}
|
||||
if delta != "" || builder.String() != "" || pending != "" {
|
||||
t.Fatalf("compacted prelude should be suppressed, got delta=%q builder=%q pending=%q", delta, builder.String(), pending)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendVisibleContentBeforeToolLeak_ClearsCompactedPreludeWhenSplit(t *testing.T) {
|
||||
var builder strings.Builder
|
||||
var pending string
|
||||
|
||||
first := "I'llcheckthedevicenodesinsidethecontainertoanswerthat.Letmecounttheentriesin/devandlisttheblockdevices."
|
||||
delta, leakFound := appendVisibleContentBeforeToolLeak(&builder, &pending, first)
|
||||
if leakFound {
|
||||
t.Fatal("first compacted chunk should not be classified as a tool leak by itself")
|
||||
}
|
||||
if delta != first || builder.String() != first {
|
||||
t.Fatalf("unexpected first compacted delta=%q builder=%q", delta, builder.String())
|
||||
}
|
||||
|
||||
delta, leakFound = appendVisibleContentBeforeToolLeak(
|
||||
&builder,
|
||||
&pending,
|
||||
"pulse_read(target_host=\"current_resource\", command=\"lsblk\")",
|
||||
)
|
||||
if !leakFound {
|
||||
t.Fatal("expected split compacted raw function call to be detected")
|
||||
}
|
||||
if delta != "" || builder.String() != "" || pending != "" {
|
||||
t.Fatalf("compacted prelude should be cleared after split leak, got delta=%q builder=%q pending=%q", delta, builder.String(), pending)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user