diff --git a/refact-agent/gui/src/components/Chat/Chat.tsx b/refact-agent/gui/src/components/Chat/Chat.tsx index 6941246b1..5b181e2bf 100644 --- a/refact-agent/gui/src/components/Chat/Chat.tsx +++ b/refact-agent/gui/src/components/Chat/Chat.tsx @@ -10,6 +10,7 @@ import { useGetCapsQuery, useCapsForToolUse, useAgentUsage, + useLastSentCompressionStop, } from "../../hooks"; import { type Config } from "../../features/Config/configSlice"; import { @@ -53,7 +54,7 @@ export const Chat: React.FC = ({ const { submit, abort, retryFromIndex } = useSendChatRequest(); const chatToolUse = useAppSelector(getSelectedToolUse); - + const compressionStop = useLastSentCompressionStop(); const threadNewChatSuggested = useAppSelector(selectThreadNewChatSuggested); const messages = useAppSelector(selectMessages); const capsForToolUse = useCapsForToolUse(); @@ -105,8 +106,9 @@ export const Chat: React.FC = ({ {!isStreaming && preventSend && unCalledTools && ( diff --git a/refact-agent/gui/src/components/ChatForm/ChatForm.tsx b/refact-agent/gui/src/components/ChatForm/ChatForm.tsx index e30d60f9c..eb689f8c6 100644 --- a/refact-agent/gui/src/components/ChatForm/ChatForm.tsx +++ b/refact-agent/gui/src/components/ChatForm/ChatForm.tsx @@ -19,7 +19,7 @@ import { useSendChatRequest, useCompressChat, useAutoFocusOnce, - useTotalTokenUsage, + // useTotalTokenUsage, } from "../../hooks"; import { ErrorCallout, Callout } from "../Callout"; import { ComboBox } from "../ComboBox"; @@ -35,7 +35,7 @@ import { useInputValue } from "./useInputValue"; import { clearInformation, getInformationMessage, - setInformation, + // setInformation, } from "../../features/Errors/informationSlice"; import { InformationCallout } from "../Callout/Callout"; import { ToolConfirmation } from "./ToolConfirmation"; @@ -98,18 +98,6 @@ export const ChatForm: React.FC = ({ const { compressChat, compressChatRequest } = useCompressChat(); const autoFocus = useAutoFocusOnce(); - const { limitReached, tokens, limit } = useTotalTokenUsage(); - - useEffect(() => { - if (limitReached) { - dispatch( - setInformation( - `Token Limit reached, ${tokens} out of ${limit} used. To continue click the compress button or start a new chat.`, - ), - ); - } - }, [tokens, limit, limitReached, dispatch]); - const shouldAgentCapabilitiesBeShown = useMemo(() => { return threadToolUse === "agent"; }, [threadToolUse]); @@ -131,7 +119,6 @@ export const ChatForm: React.FC = ({ const disableSend = useMemo(() => { // TODO: if interrupting chat some errors can occur if (allDisabled) return true; - if (limitReached) return true; // if ( // currentThreadMaximumContextTokens && // currentThreadUsage?.prompt_tokens && @@ -143,7 +130,6 @@ export const ChatForm: React.FC = ({ return isWaiting || isStreaming || !isOnline || preventSend; }, [ allDisabled, - limitReached, messages.length, isWaiting, isStreaming, @@ -321,11 +307,7 @@ export const ChatForm: React.FC = ({ if (information) { return ( - + {information} ); @@ -385,7 +367,6 @@ export const ChatForm: React.FC = ({ data-testid="chat-form-textarea" required={true} // disabled={isStreaming} - disabled={limitReached} {...props} autoFocus={autoFocus} style={{ boxShadow: "none", outline: "none" }} diff --git a/refact-agent/gui/src/components/ChatForm/SuggestNewChat/SuggestNewChat.tsx b/refact-agent/gui/src/components/ChatForm/SuggestNewChat/SuggestNewChat.tsx index 72776e0c4..359f78de6 100644 --- a/refact-agent/gui/src/components/ChatForm/SuggestNewChat/SuggestNewChat.tsx +++ b/refact-agent/gui/src/components/ChatForm/SuggestNewChat/SuggestNewChat.tsx @@ -8,13 +8,13 @@ import { useAppDispatch, useAppSelector, useCompressChat, + useLastSentCompressionStop, } from "../../../hooks"; import { popBackTo, push } from "../../../features/Pages/pagesSlice"; import { telemetryApi } from "../../../services/refact"; import { newChatAction, selectChatId, - selectLastSentCompression, setIsNewChatSuggestionRejected, } from "../../../features/Chat"; @@ -40,8 +40,8 @@ export const SuggestNewChat = ({ const [isRendered, setIsRendered] = useState(shouldBeVisible); const [isAnimating, setIsAnimating] = useState(false); const { compressChat, compressChatRequest } = useCompressChat(); + const lastSentCompression = useLastSentCompressionStop(); - const lastSentCompression = useAppSelector(selectLastSentCompression); useEffect(() => { if (shouldBeVisible) { setIsRendered(true); @@ -64,6 +64,7 @@ export const SuggestNewChat = ({ const handleClose = () => { dispatch(setIsNewChatSuggestionRejected({ chatId, value: true })); + void sendTelemetryEvent({ scope: `dismissedNewChatSuggestionWarning`, success: true, @@ -118,27 +119,28 @@ export const SuggestNewChat = ({ Start a new chat - {lastSentCompression && lastSentCompression !== "absent" && ( - { - if (compressChatRequest.isLoading) return; - void compressChat(); - }} - color="indigo" - asChild - > - { + if (compressChatRequest.isLoading) return; + void compressChat(); + }} + color="indigo" + asChild > - - Compress and open in a new chat. - - - )} + + + Compress and open in a new chat. + + + )} msg.usage); @@ -33,21 +33,23 @@ export function useUsageCounter() { }, [currentThreadUsage]); const isOverflown = useMemo(() => { - if (lastSentCompression === "low") return true; - if (lastSentCompression === "medium") return true; - if (lastSentCompression === "high") return true; + if (!compressionStop.stopped) return false; + if (compressionStop.strength === "low") return true; + if (compressionStop.strength === "medium") return true; + if (compressionStop.strength === "high") return true; return false; - }, [lastSentCompression]); + }, [compressionStop.stopped, compressionStop.strength]); const isWarning = useMemo(() => { - if (lastSentCompression === "medium") return true; - if (lastSentCompression === "high") return true; + if (!compressionStop.stopped) return false; + if (compressionStop.strength === "medium") return true; + if (compressionStop.strength === "high") return true; return false; - }, [lastSentCompression]); + }, [compressionStop.stopped, compressionStop.strength]); const shouldShow = useMemo(() => { return messages.length > 0 && !isStreaming && !isWaiting; - }, [messages, isStreaming, isWaiting]); + }, [messages.length, isStreaming, isWaiting]); return { shouldShow, diff --git a/refact-agent/gui/src/features/Chat/Thread/actions.ts b/refact-agent/gui/src/features/Chat/Thread/actions.ts index c10b0460f..bbc08eca7 100644 --- a/refact-agent/gui/src/features/Chat/Thread/actions.ts +++ b/refact-agent/gui/src/features/Chat/Thread/actions.ts @@ -63,6 +63,7 @@ export const setLastUserMessageId = createAction( "chatThread/setLastUserMessageId", ); +// TBD: only used when `/links` suggests a new chat. export const setIsNewChatSuggested = createAction( "chatThread/setIsNewChatSuggested", ); @@ -162,6 +163,10 @@ export const setIncreaseMaxTokens = createAction( "chatThread/setIncreaseMaxTokens", ); +export const setThreadPaused = createAction( + "chatThread/setThreadPaused", +); + // TODO: This is the circular dep when imported from hooks :/ const createAppAsyncThunk = createAsyncThunk.withTypes<{ state: RootState; diff --git a/refact-agent/gui/src/features/Chat/Thread/reducer.ts b/refact-agent/gui/src/features/Chat/Thread/reducer.ts index 87066d125..d0692fd28 100644 --- a/refact-agent/gui/src/features/Chat/Thread/reducer.ts +++ b/refact-agent/gui/src/features/Chat/Thread/reducer.ts @@ -39,6 +39,7 @@ import { setIsNewChatSuggestionRejected, upsertToolCall, setIncreaseMaxTokens, + setThreadPaused, } from "./actions"; import { formatChatResponse } from "./utils"; import { @@ -78,6 +79,7 @@ const createChatThread = ( boost_reasoning: false, automatic_patch: false, increase_max_tokens: false, + paused: false, }; return chat; }; @@ -241,6 +243,7 @@ export const chatReducer = createReducer(initialState, (builder) => { builder.addCase(setIsNewChatSuggested, (state, action) => { if (state.thread.id !== action.payload.chatId) return state; + state.thread.paused = true; state.thread.new_chat_suggested = { wasSuggested: action.payload.value, }; @@ -248,6 +251,7 @@ export const chatReducer = createReducer(initialState, (builder) => { builder.addCase(setIsNewChatSuggestionRejected, (state, action) => { if (state.thread.id !== action.payload.chatId) return state; + state.thread.paused = false; state.thread.new_chat_suggested = { ...state.thread.new_chat_suggested, wasRejectedByUser: action.payload.value, @@ -275,6 +279,7 @@ export const chatReducer = createReducer(initialState, (builder) => { state.streaming = true; state.thread.read = false; state.prevent_send = false; + state.thread.paused = false; }); builder.addCase(removeChatFromCache, (state, action) => { @@ -422,6 +427,10 @@ export const chatReducer = createReducer(initialState, (builder) => { state.thread.increase_max_tokens = action.payload; }); + builder.addCase(setThreadPaused, (state, action) => { + state.thread.paused = action.payload; + }); + builder.addMatcher( capsApi.endpoints.getCaps.matchFulfilled, (state, action) => { diff --git a/refact-agent/gui/src/features/Chat/Thread/selectors.ts b/refact-agent/gui/src/features/Chat/Thread/selectors.ts index 56393d114..563ba6102 100644 --- a/refact-agent/gui/src/features/Chat/Thread/selectors.ts +++ b/refact-agent/gui/src/features/Chat/Thread/selectors.ts @@ -1,9 +1,9 @@ import { RootState } from "../../../app/store"; import { createSelector } from "@reduxjs/toolkit"; import { + CompressionStrength, isToolMessage, isUserMessage, - UserMessage, } from "../../../services/refact/types"; export const selectThread = (state: RootState) => state.chat.thread; @@ -23,6 +23,7 @@ export const selectCheckpointsEnabled = (state: RootState) => export const selectThreadBoostReasoning = (state: RootState) => state.chat.thread.boost_reasoning; +// TBD: only used when `/links` suggests a new chat. export const selectThreadNewChatSuggested = (state: RootState) => state.chat.thread.new_chat_suggested; export const selectThreadMaximumTokens = (state: RootState) => @@ -77,14 +78,24 @@ export const selectThreadMode = createSelector( export const selectLastSentCompression = createSelector( selectMessages, (messages) => { - const lastUserMessage = messages.reduce( + const lastCompression = messages.reduce( (acc, message) => { - if (isUserMessage(message)) return message; + if (isUserMessage(message) && message.compression_strength) { + return message.compression_strength; + } + if (isToolMessage(message) && message.content.compression_strength) { + return message.content.compression_strength; + } return acc; }, null, ); - return lastUserMessage?.compression_strength ?? null; + return lastCompression; }, ); + +export const selectThreadPaused = createSelector( + selectThread, + (thread) => thread.paused ?? false, +); diff --git a/refact-agent/gui/src/features/Chat/Thread/types.ts b/refact-agent/gui/src/features/Chat/Thread/types.ts index 6a038aacf..7c907148b 100644 --- a/refact-agent/gui/src/features/Chat/Thread/types.ts +++ b/refact-agent/gui/src/features/Chat/Thread/types.ts @@ -29,6 +29,7 @@ export type ChatThread = { currentMaximumContextTokens?: number; currentMessageContextTokens?: number; increase_max_tokens?: boolean; + paused?: boolean; }; export type SuggestedChat = { diff --git a/refact-agent/gui/src/features/Chat/Thread/utils.ts b/refact-agent/gui/src/features/Chat/Thread/utils.ts index f6a741f47..520aa3321 100644 --- a/refact-agent/gui/src/features/Chat/Thread/utils.ts +++ b/refact-agent/gui/src/features/Chat/Thread/utils.ts @@ -182,7 +182,8 @@ export function formatChatResponse( } if (isToolResponse(response)) { - const { tool_call_id, content, finish_reason } = response; + const { tool_call_id, content, finish_reason, compression_strength } = + response; const filteredMessages = finishToolCallInMessages(messages, tool_call_id); const toolResult: ToolResult = typeof content === "string" @@ -190,11 +191,13 @@ export function formatChatResponse( tool_call_id, content, finish_reason, + compression_strength, } : { tool_call_id, content, finish_reason, + compression_strength, }; return [...filteredMessages, { role: response.role, content: toolResult }]; diff --git a/refact-agent/gui/src/hooks/index.ts b/refact-agent/gui/src/hooks/index.ts index 3ffb2015a..6dfb21e34 100644 --- a/refact-agent/gui/src/hooks/index.ts +++ b/refact-agent/gui/src/hooks/index.ts @@ -34,4 +34,4 @@ export * from "./useResizeObserver"; export * from "./useCompressChat"; export * from "./useAutoFocusOnce"; export * from "./useHideScroll"; -export * from "./useTotalTokenUsage"; +export * from "./useCompressionStop"; diff --git a/refact-agent/gui/src/hooks/useCompressionStop.ts b/refact-agent/gui/src/hooks/useCompressionStop.ts new file mode 100644 index 000000000..aad6687be --- /dev/null +++ b/refact-agent/gui/src/hooks/useCompressionStop.ts @@ -0,0 +1,45 @@ +import { useEffect, useCallback, useMemo } from "react"; +import { useAppSelector } from "./useAppSelector"; +import { useAppDispatch } from "./useAppDispatch"; +import { + selectLastSentCompression, + selectMessages, + selectThreadPaused, + setThreadPaused, +} from "../features/Chat"; +import { takeFromEndWhile } from "../utils"; +import { isUserMessage } from "../events"; + +export function useLastSentCompressionStop() { + const dispatch = useAppDispatch(); + const lastSentCompression = useAppSelector(selectLastSentCompression); + const messages = useAppSelector(selectMessages); + const stopped = useAppSelector(selectThreadPaused); + useEffect(() => { + if (lastSentCompression && lastSentCompression !== "absent" && !stopped) { + dispatch(setThreadPaused(true)); + } + }, [dispatch, lastSentCompression, stopped]); + + const messagesFromLastUserMessage = useMemo(() => { + return takeFromEndWhile(messages, (message) => !isUserMessage(message)) + .length; + }, [messages]); + + useEffect(() => { + if ( + lastSentCompression && + lastSentCompression !== "absent" && + messagesFromLastUserMessage >= 40 && + !stopped + ) { + dispatch(setThreadPaused(true)); + } + }, [dispatch, lastSentCompression, messagesFromLastUserMessage, stopped]); + + const resume = useCallback(() => { + dispatch(setThreadPaused(false)); + }, [dispatch]); + + return { stopped, resume, strength: lastSentCompression }; +} diff --git a/refact-agent/gui/src/hooks/useSendChatRequest.ts b/refact-agent/gui/src/hooks/useSendChatRequest.ts index c515ddd4f..05d74e4c4 100644 --- a/refact-agent/gui/src/hooks/useSendChatRequest.ts +++ b/refact-agent/gui/src/hooks/useSendChatRequest.ts @@ -56,7 +56,7 @@ import { import { v4 as uuidv4 } from "uuid"; import { upsertToolCallIntoHistory } from "../features/History/historySlice"; -import { useTotalTokenUsage } from "./useTotalTokenUsage"; +import { useLastSentCompressionStop } from "./useCompressionStop"; type SubmitHandlerParams = | { @@ -356,7 +356,7 @@ export function useAutoSend() { const sendImmediately = useAppSelector(selectSendImmediately); const wasInteracted = useAppSelector(getToolsInteractionStatus); // shows if tool confirmation popup was interacted by user const areToolsConfirmed = useAppSelector(getToolsConfirmationStatus); - const { limitReached } = useTotalTokenUsage(); + const compressionStop = useLastSentCompressionStop(); const { sendMessages, abort, messagesWithSystemPrompt } = useSendChatRequest(); // TODO: make a selector for this, or show tool formation @@ -381,7 +381,7 @@ export function useAutoSend() { const lastMessage = currentMessages.slice(-1)[0]; // here ish if ( - !limitReached && + !compressionStop.stopped && isAssistantMessage(lastMessage) && lastMessage.tool_calls && lastMessage.tool_calls.length > 0 @@ -418,6 +418,6 @@ export function useAutoSend() { isIntegration, thread.mode, thread, - limitReached, + compressionStop.stopped, ]); } diff --git a/refact-agent/gui/src/hooks/useTotalTokenUsage.ts b/refact-agent/gui/src/hooks/useTotalTokenUsage.ts deleted file mode 100644 index 5c2a3f7e2..000000000 --- a/refact-agent/gui/src/hooks/useTotalTokenUsage.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { useMemo } from "react"; -import { useAppSelector } from "./useAppSelector"; -import { isAssistantMessage } from "../events"; -import { selectMessages } from "../features/Chat"; -import { type Usage } from "../services/refact/chat"; -import { - calculateUsageInputTokens, - mergeUsages, -} from "../utils/calculateUsageInputTokens"; - -const TOKEN_LIMIT = 200_000; -export function useTotalTokenUsage() { - const messages = useAppSelector(selectMessages); - - const summedUsages = useMemo(() => { - const usages = messages.reduce((acc, message) => { - if (isAssistantMessage(message) && message.usage) { - return [...acc, message.usage]; - } - return acc; - }, []); - return mergeUsages(usages); - }, [messages]); - - const tokens = useMemo(() => { - if (!summedUsages) return 0; - return calculateUsageInputTokens({ - keys: [ - "prompt_tokens", - "cache_creation_input_tokens", - "cache_read_input_tokens", - ], - usage: summedUsages, - }); - }, [summedUsages]); - - const limitReached = useMemo(() => { - return tokens >= TOKEN_LIMIT; - }, [tokens]); - - return { - summedUsages, - tokens, - limitReached, - limit: TOKEN_LIMIT, - }; -} diff --git a/refact-agent/gui/src/services/refact/types.ts b/refact-agent/gui/src/services/refact/types.ts index 0fafc5b1a..eef22c1a4 100644 --- a/refact-agent/gui/src/services/refact/types.ts +++ b/refact-agent/gui/src/services/refact/types.ts @@ -70,6 +70,7 @@ export interface BaseToolResult { tool_call_id: string; finish_reason?: string; // "call_failed" | "call_worked"; content: ToolContent; + compression_strength?: CompressionStrength; } export interface SingleModelToolResult extends BaseToolResult {