ctx_management: summarize on command button (#2479)

This commit is contained in:
Lily Delalande
2025-05-08 10:43:53 -07:00
committed by GitHub
parent 85dd6375b5
commit 01e28423ff
18 changed files with 390 additions and 74 deletions
+2 -1
View File
@@ -5,7 +5,7 @@ use goose::config::permission::PermissionLevel;
use goose::config::ExtensionEntry; use goose::config::ExtensionEntry;
use goose::message::{ use goose::message::{
ContextLengthExceeded, FrontendToolRequest, Message, MessageContent, RedactedThinkingContent, ContextLengthExceeded, FrontendToolRequest, Message, MessageContent, RedactedThinkingContent,
ThinkingContent, ToolConfirmationRequest, ToolRequest, ToolResponse, SummarizationRequested, ThinkingContent, ToolConfirmationRequest, ToolRequest, ToolResponse,
}; };
use goose::permission::permission_confirmation::PrincipalType; use goose::permission::permission_confirmation::PrincipalType;
use goose::providers::base::{ConfigKey, ModelInfo, ProviderMetadata}; use goose::providers::base::{ConfigKey, ModelInfo, ProviderMetadata};
@@ -70,6 +70,7 @@ use utoipa::OpenApi;
FrontendToolRequest, FrontendToolRequest,
ResourceContents, ResourceContents,
ContextLengthExceeded, ContextLengthExceeded,
SummarizationRequested,
Role, Role,
ProviderMetadata, ProviderMetadata,
ExtensionEntry, ExtensionEntry,
+24
View File
@@ -91,6 +91,11 @@ pub struct ContextLengthExceeded {
pub msg: String, pub msg: String,
} }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
pub struct SummarizationRequested {
pub msg: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
/// Content passed inside a message, which can be both simple content and tool content /// Content passed inside a message, which can be both simple content and tool content
#[serde(tag = "type", rename_all = "camelCase")] #[serde(tag = "type", rename_all = "camelCase")]
@@ -104,6 +109,7 @@ pub enum MessageContent {
Thinking(ThinkingContent), Thinking(ThinkingContent),
RedactedThinking(RedactedThinkingContent), RedactedThinking(RedactedThinkingContent),
ContextLengthExceeded(ContextLengthExceeded), ContextLengthExceeded(ContextLengthExceeded),
SummarizationRequested(SummarizationRequested),
} }
impl MessageContent { impl MessageContent {
@@ -172,6 +178,19 @@ impl MessageContent {
MessageContent::ContextLengthExceeded(ContextLengthExceeded { msg: msg.into() }) MessageContent::ContextLengthExceeded(ContextLengthExceeded { msg: msg.into() })
} }
pub fn summarization_requested<S: Into<String>>(msg: S) -> Self {
MessageContent::SummarizationRequested(SummarizationRequested { msg: msg.into() })
}
// Add this new method to check for summarization requested content
pub fn as_summarization_requested(&self) -> Option<&SummarizationRequested> {
if let MessageContent::SummarizationRequested(ref summarization_requested) = self {
Some(summarization_requested)
} else {
None
}
}
pub fn as_tool_request(&self) -> Option<&ToolRequest> { pub fn as_tool_request(&self) -> Option<&ToolRequest> {
if let MessageContent::ToolRequest(ref tool_request) = self { if let MessageContent::ToolRequest(ref tool_request) = self {
Some(tool_request) Some(tool_request)
@@ -451,6 +470,11 @@ impl Message {
.iter() .iter()
.all(|c| matches!(c, MessageContent::Text(_))) .all(|c| matches!(c, MessageContent::Text(_)))
} }
/// Add summarization requested to the message
pub fn with_summarization_requested<S: Into<String>>(self, msg: S) -> Self {
self.with_content(MessageContent::summarization_requested(msg))
}
} }
#[cfg(test)] #[cfg(test)]
@@ -63,6 +63,9 @@ pub fn format_messages(messages: &[Message]) -> Vec<Value> {
MessageContent::ContextLengthExceeded(_) => { MessageContent::ContextLengthExceeded(_) => {
// Skip // Skip
} }
MessageContent::SummarizationRequested(_) => {
// Skip
}
MessageContent::Thinking(thinking) => { MessageContent::Thinking(thinking) => {
content.push(json!({ content.push(json!({
"type": "thinking", "type": "thinking",
@@ -45,6 +45,9 @@ pub fn to_bedrock_message_content(content: &MessageContent) -> Result<bedrock::C
MessageContent::ContextLengthExceeded(_) => { MessageContent::ContextLengthExceeded(_) => {
bail!("ContextLengthExceeded should not get passed to the provider") bail!("ContextLengthExceeded should not get passed to the provider")
} }
MessageContent::SummarizationRequested(_) => {
bail!("SummarizationRequested should not get passed to the provider")
}
MessageContent::ToolRequest(tool_req) => { MessageContent::ToolRequest(tool_req) => {
let tool_use_id = tool_req.id.to_string(); let tool_use_id = tool_req.id.to_string();
let tool_use = if let Ok(call) = tool_req.tool_call.as_ref() { let tool_use = if let Ok(call) = tool_req.tool_call.as_ref() {
@@ -113,6 +113,9 @@ pub fn format_messages(messages: &[Message], image_format: &ImageFormat) -> Vec<
MessageContent::ContextLengthExceeded(_) => { MessageContent::ContextLengthExceeded(_) => {
continue; continue;
} }
MessageContent::SummarizationRequested(_) => {
continue;
}
MessageContent::ToolResponse(response) => { MessageContent::ToolResponse(response) => {
match &response.tool_result { match &response.tool_result {
Ok(contents) => { Ok(contents) => {
@@ -55,6 +55,9 @@ pub fn format_messages(messages: &[Message], image_format: &ImageFormat) -> Vec<
MessageContent::ContextLengthExceeded(_) => { MessageContent::ContextLengthExceeded(_) => {
continue; continue;
} }
MessageContent::SummarizationRequested(_) => {
continue;
}
MessageContent::ToolRequest(request) => match &request.tool_call { MessageContent::ToolRequest(request) => match &request.tool_call {
Ok(tool_call) => { Ok(tool_call) => {
let sanitized_name = sanitize_function_name(&tool_call.name); let sanitized_name = sanitize_function_name(&tool_call.name);
+32
View File
@@ -1244,6 +1244,27 @@
} }
} }
] ]
},
{
"allOf": [
{
"$ref": "#/components/schemas/SummarizationRequested"
},
{
"type": "object",
"required": [
"type"
],
"properties": {
"type": {
"type": "string",
"enum": [
"summarizationRequested"
]
}
}
}
]
} }
], ],
"description": "Content passed inside a message, which can be both simple content and tool content", "description": "Content passed inside a message, which can be both simple content and tool content",
@@ -1571,6 +1592,17 @@
} }
} }
}, },
"SummarizationRequested": {
"type": "object",
"required": [
"msg"
],
"properties": {
"msg": {
"type": "string"
}
}
},
"TextContent": { "TextContent": {
"type": "object", "type": "object",
"required": [ "required": [
-3
View File
@@ -503,9 +503,6 @@ export default function App() {
// If GOOSE_ALLOWLIST_WARNING is true, use warning-only mode (STRICT_ALLOWLIST=false) // If GOOSE_ALLOWLIST_WARNING is true, use warning-only mode (STRICT_ALLOWLIST=false)
// If GOOSE_ALLOWLIST_WARNING is not set or false, use strict blocking mode (STRICT_ALLOWLIST=true) // If GOOSE_ALLOWLIST_WARNING is not set or false, use strict blocking mode (STRICT_ALLOWLIST=true)
const STRICT_ALLOWLIST = config.GOOSE_ALLOWLIST_WARNING === true ? false : true; const STRICT_ALLOWLIST = config.GOOSE_ALLOWLIST_WARNING === true ? false : true;
console.log(
`Extension security mode: ${STRICT_ALLOWLIST ? 'Strict' : 'Warning-only'} (GOOSE_ALLOWLIST_WARNING=${config.GOOSE_ALLOWLIST_WARNING})`
);
useEffect(() => { useEffect(() => {
console.log('Setting up extension handler'); console.log('Setting up extension handler');
+6
View File
@@ -196,6 +196,8 @@ export type MessageContent = (TextContent & {
type: 'redactedThinking'; type: 'redactedThinking';
}) | (ContextLengthExceeded & { }) | (ContextLengthExceeded & {
type: 'contextLengthExceeded'; type: 'contextLengthExceeded';
}) | (SummarizationRequested & {
type: 'summarizationRequested';
}); });
/** /**
@@ -360,6 +362,10 @@ export type SessionMetadata = {
working_dir: string; working_dir: string;
}; };
export type SummarizationRequested = {
msg: string;
};
export type TextContent = { export type TextContent = {
annotations?: Annotations | null; annotations?: Annotations | null;
text: string; text: string;
+13 -1
View File
@@ -6,6 +6,7 @@ import { Attach, Send } from './icons';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import BottomMenu from './bottom_menu/BottomMenu'; import BottomMenu from './bottom_menu/BottomMenu';
import { LocalMessageStorage } from '../utils/localMessageStorage'; import { LocalMessageStorage } from '../utils/localMessageStorage';
import { Message } from '../types/message';
interface ChatInputProps { interface ChatInputProps {
handleSubmit: (e: React.FormEvent) => void; handleSubmit: (e: React.FormEvent) => void;
@@ -16,6 +17,9 @@ interface ChatInputProps {
droppedFiles?: string[]; droppedFiles?: string[];
setView: (view: View) => void; setView: (view: View) => void;
numTokens?: number; numTokens?: number;
hasMessages?: boolean;
messages?: Message[];
setMessages: (messages: Message[]) => void;
} }
export default function ChatInput({ export default function ChatInput({
@@ -27,6 +31,8 @@ export default function ChatInput({
setView, setView,
numTokens, numTokens,
droppedFiles = [], droppedFiles = [],
messages = [],
setMessages,
}: ChatInputProps) { }: ChatInputProps) {
const [_value, setValue] = useState(initialValue); const [_value, setValue] = useState(initialValue);
const [displayValue, setDisplayValue] = useState(initialValue); // For immediate visual feedback const [displayValue, setDisplayValue] = useState(initialValue); // For immediate visual feedback
@@ -327,7 +333,13 @@ export default function ChatInput({
<Attach /> <Attach />
</Button> </Button>
<BottomMenu setView={setView} numTokens={numTokens} /> <BottomMenu
setView={setView}
numTokens={numTokens}
messages={messages}
isLoading={isLoading}
setMessages={setMessages}
/>
</div> </div>
</div> </div>
</div> </div>
+24 -8
View File
@@ -22,8 +22,8 @@ import { Recipe } from '../recipe';
import { import {
ChatContextManagerProvider, ChatContextManagerProvider,
useChatContextManager, useChatContextManager,
} from './context_management/ContextManager'; } from './context_management/ChatContextManager';
import { ContextLengthExceededHandler } from './context_management/ContextLengthExceededHandler'; import { ContextHandler } from './context_management/ContextHandler';
import { LocalMessageStorage } from '../utils/localMessageStorage'; import { LocalMessageStorage } from '../utils/localMessageStorage';
import { import {
Message, Message,
@@ -105,7 +105,8 @@ function ChatContent({
resetMessagesWithSummary, resetMessagesWithSummary,
closeSummaryModal, closeSummaryModal,
updateSummary, updateSummary,
hasContextLengthExceededContent, hasContextHandlerContent,
getContextHandlerType,
} = useChatContextManager(); } = useChatContextManager();
useEffect(() => { useEffect(() => {
@@ -521,16 +522,29 @@ function ChatContent({
data-testid="message-container" data-testid="message-container"
> >
{isUserMessage(message) ? ( {isUserMessage(message) ? (
<UserMessage message={message} />
) : (
<> <>
{/* Only render GooseMessage if it's not a CLE message */} {hasContextHandlerContent(message) ? (
{hasContextLengthExceededContent(message) ? ( <ContextHandler
<ContextLengthExceededHandler
messages={messages} messages={messages}
messageId={message.id ?? message.created.toString()} messageId={message.id ?? message.created.toString()}
chatId={chat.id} chatId={chat.id}
workingDir={window.appConfig.get('GOOSE_WORKING_DIR') as string} workingDir={window.appConfig.get('GOOSE_WORKING_DIR') as string}
contextType={getContextHandlerType(message)}
/>
) : (
<UserMessage message={message} />
)}
</>
) : (
<>
{/* Only render GooseMessage if it's not a message invoking some context management */}
{hasContextHandlerContent(message) ? (
<ContextHandler
messages={messages}
messageId={message.id ?? message.created.toString()}
chatId={chat.id}
workingDir={window.appConfig.get('GOOSE_WORKING_DIR') as string}
contextType={getContextHandlerType(message)}
/> />
) : ( ) : (
<GooseMessage <GooseMessage
@@ -587,6 +601,8 @@ function ChatContent({
hasMessages={hasMessages} hasMessages={hasMessages}
numTokens={sessionTokenCount} numTokens={sessionTokenCount}
droppedFiles={droppedFiles} droppedFiles={droppedFiles}
messages={messages}
setMessages={setMessages}
/> />
</div> </div>
</Card> </Card>
@@ -12,6 +12,8 @@ import { BottomMenuModeSelection } from './BottomMenuModeSelection';
import ModelsBottomBar from '../settings_v2/models/bottom_bar/ModelsBottomBar'; import ModelsBottomBar from '../settings_v2/models/bottom_bar/ModelsBottomBar';
import { useConfig } from '../ConfigContext'; import { useConfig } from '../ConfigContext';
import { getCurrentModelAndProvider } from '../settings_v2/models'; import { getCurrentModelAndProvider } from '../settings_v2/models';
import { Message } from '../../types/message';
import { ManualSummarizeButton } from '../context_management/ManualSummaryButton';
const TOKEN_LIMIT_DEFAULT = 128000; // fallback for custom models that the backend doesn't know about const TOKEN_LIMIT_DEFAULT = 128000; // fallback for custom models that the backend doesn't know about
const TOKEN_WARNING_THRESHOLD = 0.8; // warning shows at 80% of the token limit const TOKEN_WARNING_THRESHOLD = 0.8; // warning shows at 80% of the token limit
@@ -20,9 +22,15 @@ const TOOLS_MAX_SUGGESTED = 60; // max number of tools before we show a warning
export default function BottomMenu({ export default function BottomMenu({
setView, setView,
numTokens = 0, numTokens = 0,
messages = [],
isLoading = false,
setMessages,
}: { }: {
setView: (view: View, viewOptions?: ViewOptions) => void; setView: (view: View, viewOptions?: ViewOptions) => void;
numTokens?: number; numTokens?: number;
messages?: Message[];
isLoading?: boolean;
setMessages: (messages: Message[]) => void;
}) { }) {
const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
const { currentModel } = useModel(); const { currentModel } = useModel();
@@ -267,6 +275,18 @@ export default function BottomMenu({
{/* Goose Mode Selector Dropdown */} {/* Goose Mode Selector Dropdown */}
<BottomMenuModeSelection setView={setView} /> <BottomMenuModeSelection setView={setView} />
{/* Summarize Context Button - ADD THIS */}
{messages.length > 0 && (
<>
<div className="w-[1px] h-4 bg-borderSubtle mx-2" />
<ManualSummarizeButton
messages={messages}
isLoading={isLoading}
setMessages={setMessages}
/>
</>
)}
</div> </div>
</div> </div>
); );
@@ -1,6 +1,10 @@
import React, { createContext, useContext, useState } from 'react'; import React, { createContext, useContext, useState } from 'react';
import { Message } from '../../types/message'; import { Message } from '../../types/message';
import { manageContextFromBackend, convertApiMessageToFrontendMessage } from './index'; import {
manageContextFromBackend,
convertApiMessageToFrontendMessage,
createSummarizationRequestMessage,
} from './index';
// Define the context management interface // Define the context management interface
interface ChatContextManagerState { interface ChatContextManagerState {
@@ -9,10 +13,10 @@ interface ChatContextManagerState {
isSummaryModalOpen: boolean; isSummaryModalOpen: boolean;
isLoadingSummary: boolean; isLoadingSummary: boolean;
errorLoadingSummary: boolean; errorLoadingSummary: boolean;
preparingManualSummary: boolean;
} }
interface ChatContextManagerActions { interface ChatContextManagerActions {
fetchSummary: (messages: Message[]) => Promise<void>;
updateSummary: (newSummaryContent: string) => void; updateSummary: (newSummaryContent: string) => void;
resetMessagesWithSummary: ( resetMessagesWithSummary: (
messages: Message[], messages: Message[],
@@ -23,12 +27,15 @@ interface ChatContextManagerActions {
) => void; ) => void;
openSummaryModal: () => void; openSummaryModal: () => void;
closeSummaryModal: () => void; closeSummaryModal: () => void;
hasContextHandlerContent: (message: Message) => boolean;
hasContextLengthExceededContent: (message: Message) => boolean; hasContextLengthExceededContent: (message: Message) => boolean;
handleContextLengthExceeded: ( hasSummarizationRequestedContent: (message: Message) => boolean;
getContextHandlerType: (message: Message) => 'contextLengthExceeded' | 'summarizationRequested';
handleContextLengthExceeded: (messages: Message[]) => Promise<void>;
handleManualSummarization: (
messages: Message[], messages: Message[],
chatId: string, setMessages: (messages: Message[]) => void
workingDir: string ) => void;
) => Promise<void>;
} }
// Create the context // Create the context
@@ -45,10 +52,12 @@ export const ChatContextManagerProvider: React.FC<{ children: React.ReactNode }>
const [isSummaryModalOpen, setIsSummaryModalOpen] = useState<boolean>(false); const [isSummaryModalOpen, setIsSummaryModalOpen] = useState<boolean>(false);
const [isLoadingSummary, setIsLoadingSummary] = useState<boolean>(false); const [isLoadingSummary, setIsLoadingSummary] = useState<boolean>(false);
const [errorLoadingSummary, setErrorLoadingSummary] = useState<boolean>(false); const [errorLoadingSummary, setErrorLoadingSummary] = useState<boolean>(false);
const [preparingManualSummary, setPreparingManualSummary] = useState<boolean>(false);
const handleContextLengthExceeded = async (messages: Message[]): Promise<void> => { const handleContextLengthExceeded = async (messages: Message[]): Promise<void> => {
setIsLoadingSummary(true); setIsLoadingSummary(true);
setErrorLoadingSummary(false); setErrorLoadingSummary(false);
setPreparingManualSummary(true);
try { try {
// 2. Now get the summary from the backend // 2. Now get the summary from the backend
@@ -75,38 +84,25 @@ export const ChatContextManagerProvider: React.FC<{ children: React.ReactNode }>
console.error('Error handling context length exceeded:', err); console.error('Error handling context length exceeded:', err);
setErrorLoadingSummary(true); setErrorLoadingSummary(true);
setIsLoadingSummary(false); setIsLoadingSummary(false);
} finally {
setPreparingManualSummary(false);
} }
}; };
const fetchSummary = async (messages: Message[]) => { const handleManualSummarization = (
setIsLoadingSummary(true); messages: Message[],
setErrorLoadingSummary(false); setMessages: (messages: Message[]) => void
): void => {
// add some messages to the message thread
// these messages will be filtered out in chat view
// but they will also be what allows us to render some text in the chatview itself, similar to CLE events
const summarizationRequest = createSummarizationRequestMessage(
messages,
'Summarize the session and begin a new one'
);
try { // add the message to the message thread
const response = await manageContextFromBackend({ setMessages([...messages, summarizationRequest]);
messages: messages,
manageAction: 'summarize',
});
// Convert API messages to frontend messages
const convertedMessages = response.messages.map(
(apiMessage) => convertApiMessageToFrontendMessage(apiMessage, false, true) // do not show to user but send to llm
);
// Extract the summary text from the first message
const summaryMessage = convertedMessages[0].content[0];
if (summaryMessage.type === 'text') {
const summary = summaryMessage.text;
setSummaryContent(summary);
setSummarizedThread(convertedMessages);
}
setIsLoadingSummary(false);
} catch (err) {
console.error('Error fetching summary:', err);
setErrorLoadingSummary(true);
setIsLoadingSummary(false);
}
}; };
const updateSummary = (newSummaryContent: string) => { const updateSummary = (newSummaryContent: string) => {
@@ -212,10 +208,27 @@ export const ChatContextManagerProvider: React.FC<{ children: React.ReactNode }>
setSummaryContent(''); setSummaryContent('');
}; };
const hasContextHandlerContent = (message: Message): boolean => {
return hasContextLengthExceededContent(message) || hasSummarizationRequestedContent(message);
};
const hasContextLengthExceededContent = (message: Message): boolean => { const hasContextLengthExceededContent = (message: Message): boolean => {
return message.content.some((content) => content.type === 'contextLengthExceeded'); return message.content.some((content) => content.type === 'contextLengthExceeded');
}; };
const hasSummarizationRequestedContent = (message: Message): boolean => {
return message.content.some((content) => content.type === 'summarizationRequested');
};
const getContextHandlerType = (
message: Message
): 'contextLengthExceeded' | 'summarizationRequested' => {
if (hasContextLengthExceededContent(message)) {
return 'contextLengthExceeded';
}
return 'summarizationRequested';
};
const openSummaryModal = () => { const openSummaryModal = () => {
setIsSummaryModalOpen(true); setIsSummaryModalOpen(true);
}; };
@@ -231,15 +244,19 @@ export const ChatContextManagerProvider: React.FC<{ children: React.ReactNode }>
isSummaryModalOpen, isSummaryModalOpen,
isLoadingSummary, isLoadingSummary,
errorLoadingSummary, errorLoadingSummary,
preparingManualSummary,
// Actions // Actions
fetchSummary,
updateSummary, updateSummary,
resetMessagesWithSummary, resetMessagesWithSummary,
openSummaryModal, openSummaryModal,
closeSummaryModal, closeSummaryModal,
hasContextHandlerContent,
hasContextLengthExceededContent, hasContextLengthExceededContent,
hasSummarizationRequestedContent,
getContextHandlerType,
handleContextLengthExceeded, handleContextLengthExceeded,
handleManualSummarization,
}; };
return ( return (
@@ -1,19 +1,21 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { Message } from '../../types/message'; import { Message } from '../../types/message';
import { useChatContextManager } from './ContextManager'; import { useChatContextManager } from './ChatContextManager';
interface ContextLengthExceededHandlerProps { interface ContextHandlerProps {
messages: Message[]; messages: Message[];
messageId: string; messageId: string;
chatId: string; chatId: string;
workingDir: string; workingDir: string;
contextType: 'contextLengthExceeded' | 'summarizationRequested';
} }
export const ContextLengthExceededHandler: React.FC<ContextLengthExceededHandlerProps> = ({ export const ContextHandler: React.FC<ContextHandlerProps> = ({
messages, messages,
messageId, messageId,
chatId, chatId,
workingDir, workingDir,
contextType,
}) => { }) => {
const { const {
summaryContent, summaryContent,
@@ -22,10 +24,11 @@ export const ContextLengthExceededHandler: React.FC<ContextLengthExceededHandler
openSummaryModal, openSummaryModal,
handleContextLengthExceeded, handleContextLengthExceeded,
} = useChatContextManager(); } = useChatContextManager();
const [hasFetchStarted, setHasFetchStarted] = useState(false); const [hasFetchStarted, setHasFetchStarted] = useState(false);
const [retryCount, setRetryCount] = useState(0); const [retryCount, setRetryCount] = useState(0);
const isContextLengthExceeded = contextType === 'contextLengthExceeded';
// Find the relevant message to check if it's the latest // Find the relevant message to check if it's the latest
const isCurrentMessageLatest = const isCurrentMessageLatest =
messageId === messages[messages.length - 1]?.id || messageId === messages[messages.length - 1]?.id ||
@@ -43,7 +46,7 @@ export const ContextLengthExceededHandler: React.FC<ContextLengthExceededHandler
fetchStartedRef.current = true; fetchStartedRef.current = true;
// Call the async function without awaiting it in useEffect // Call the async function without awaiting it in useEffect
handleContextLengthExceeded(messages, chatId, workingDir).catch((err) => { handleContextLengthExceeded(messages).catch((err) => {
console.error('Error handling context length exceeded:', err); console.error('Error handling context length exceeded:', err);
}); });
}; };
@@ -109,8 +112,16 @@ export const ContextLengthExceededHandler: React.FC<ContextLengthExceededHandler
const renderFailedState = () => ( const renderFailedState = () => (
<> <>
<span className="text-xs text-gray-400">{`Your conversation has exceeded the model's context capacity`}</span> <span className="text-xs text-gray-400">
<span className="text-xs text-gray-400">{`This conversation has too much information to continue. Extension data often takes up significant space.`}</span> {isContextLengthExceeded
? `Your conversation has exceeded the model's context capacity`
: `Summarization requested`}
</span>
<span className="text-xs text-gray-400">
{isContextLengthExceeded
? `This conversation has too much information to continue. Extension data often takes up significant space.`
: `Summarization failed. Continue chatting or start a new session.`}
</span>
<button <button
onClick={openNewSession} onClick={openNewSession}
className="text-xs text-textStandard hover:text-textSubtle transition-colors mt-1 flex items-center" className="text-xs text-textStandard hover:text-textSubtle transition-colors mt-1 flex items-center"
@@ -122,7 +133,11 @@ export const ContextLengthExceededHandler: React.FC<ContextLengthExceededHandler
const renderRetryState = () => ( const renderRetryState = () => (
<> <>
<span className="text-xs text-gray-400">{`Your conversation has exceeded the model's context capacity`}</span> <span className="text-xs text-gray-400">
{isContextLengthExceeded
? `Your conversation has exceeded the model's context capacity`
: `Summarization requested`}
</span>
<button <button
onClick={handleRetry} onClick={handleRetry}
className="text-xs text-textStandard hover:text-textSubtle transition-colors mt-1 flex items-center" className="text-xs text-textStandard hover:text-textSubtle transition-colors mt-1 flex items-center"
@@ -134,27 +149,57 @@ export const ContextLengthExceededHandler: React.FC<ContextLengthExceededHandler
const renderSuccessState = () => ( const renderSuccessState = () => (
<> <>
<span className="text-xs text-gray-400">{`Your conversation has exceeded the model's context capacity and a summary was prepared.`}</span> <span className="text-xs text-gray-400">
<span className="text-xs text-gray-400">{`Messages above this line remain viewable but specific details are not included in active context.`}</span> {isContextLengthExceeded
<button ? `Your conversation has exceeded the model's context capacity and a summary was prepared.`
onClick={openSummaryModal} : `A summary of your conversation was prepared as requested.`}
className="text-xs text-textStandard hover:text-textSubtle transition-colors mt-1 flex items-center" </span>
> <span className="text-xs text-gray-400">
View or edit summary (you may continue your conversation based on the summary) {isContextLengthExceeded
</button> ? `Messages above this line remain viewable but specific details are not included in active context.`
: `This summary includes key points from your conversation.`}
</span>
{shouldAllowSummaryInteraction && (
<button
onClick={openSummaryModal}
className="text-xs text-textStandard hover:text-textSubtle transition-colors mt-1 flex items-center"
>
View or edit summary{' '}
{isContextLengthExceeded
? '(you may continue your conversation based on the summary)'
: ''}
</button>
)}
</> </>
); );
// Render persistent summarized notification when we shouldn't show interaction options
const renderPersistentMarker = () => (
<span className="text-xs text-gray-400">
Session summarized messages above this line are not included in the conversation
</span>
);
const renderContentState = () => { const renderContentState = () => {
if (!shouldAllowSummaryInteraction) { // If this is not the latest context event message but we have a valid summary,
return null; // show the persistent marker
if (!shouldAllowSummaryInteraction && summaryContent) {
return renderPersistentMarker();
} }
if (errorLoadingSummary) { // For the latest message with the context event
return retryCount >= 2 ? renderFailedState() : renderRetryState(); if (shouldAllowSummaryInteraction) {
if (errorLoadingSummary) {
return retryCount >= 2 ? renderFailedState() : renderRetryState();
}
if (summaryContent) {
return renderSuccessState();
}
} }
return renderSuccessState(); // Fallback to showing at least the persistent marker
return renderPersistentMarker();
}; };
return ( return (
@@ -0,0 +1,97 @@
import React, { useState } from 'react';
import { ScrollText } from 'lucide-react';
import Modal from '../Modal';
import { Button } from '../ui/button';
import { useChatContextManager } from './ChatContextManager';
import { Message } from '../../types/message';
interface ManualSummarizeButtonProps {
messages: Message[];
isLoading?: boolean; // need this prop to know if Goose is responding
setMessages: (messages: Message[]) => void; // context management is triggered via special message content types
}
export const ManualSummarizeButton: React.FC<ManualSummarizeButtonProps> = ({
messages,
isLoading = false,
setMessages,
}) => {
const { handleManualSummarization, isLoadingSummary } = useChatContextManager();
const [isConfirmationOpen, setIsConfirmationOpen] = useState(false);
const handleClick = () => {
setIsConfirmationOpen(true);
};
const handleSummarize = async () => {
setIsConfirmationOpen(false);
try {
handleManualSummarization(messages, setMessages);
} catch (error) {
console.error('Error in handleSummarize:', error);
}
};
// Footer content for the confirmation modal
const footerContent = (
<>
<Button
onClick={handleSummarize}
className="w-full h-[60px] rounded-none border-b border-borderSubtle bg-transparent hover:bg-bgSubtle text-textProminent font-medium text-large"
>
Summarize
</Button>
<Button
onClick={() => setIsConfirmationOpen(false)}
variant="ghost"
className="w-full h-[60px] rounded-none hover:bg-bgSubtle text-textSubtle hover:text-textStandard text-large font-regular"
>
Cancel
</Button>
</>
);
return (
<>
<div className="relative flex items-center">
<button
className={`flex items-center justify-center text-textSubtle hover:text-textStandard h-6 [&_svg]:size-4 ${
isLoadingSummary || isLoading ? 'opacity-50 cursor-not-allowed' : ''
}`}
onClick={handleClick}
disabled={isLoadingSummary || isLoading}
title="Summarize conversation context"
>
<ScrollText size={16} />
</button>
</div>
{/* Confirmation Modal */}
{isConfirmationOpen && (
<Modal footer={footerContent} onClose={() => setIsConfirmationOpen(false)}>
<div className="flex flex-col mb-6">
<div>
<ScrollText className="text-iconStandard" size={24} />
</div>
<div className="mt-2">
<h2 className="text-2xl font-regular text-textStandard">Summarize Conversation</h2>
</div>
</div>
<div className="mb-6">
<p className="text-textStandard mb-4">
This will summarize your conversation history to save context space.
</p>
<p className="text-textStandard">
Previous messages will remain visible but only the summary will be included in the
active context for Goose. This is useful for long conversations that are approaching
the context limit.
</p>
</div>
</Modal>
)}
</>
);
};
@@ -4,6 +4,7 @@ import {
MessageContent as FrontendMessageContent, MessageContent as FrontendMessageContent,
ToolCallResult, ToolCallResult,
ToolCall, ToolCall,
Role,
} from '../../types/message'; } from '../../types/message';
import { import {
ContextManageRequest, ContextManageRequest,
@@ -115,9 +116,40 @@ function mapApiContentToFrontendMessageContent(
type: 'contextLengthExceeded', type: 'contextLengthExceeded',
msg: apiContent.msg, msg: apiContent.msg,
}; };
} else if (apiContent.type === 'summarizationRequested') {
return {
type: 'summarizationRequested',
msg: apiContent.msg,
};
} }
// For types that exist in API but not in frontend, either skip or convert // For types that exist in API but not in frontend, either skip or convert
console.warn(`Skipping unsupported content type: ${apiContent.type}`); console.warn(`Skipping unsupported content type: ${apiContent.type}`);
return null; return null;
} }
export function createSummarizationRequestMessage(
messages: FrontendMessage[],
requestMessage: string
): FrontendMessage {
// Get the last message
const lastMessage = messages[messages.length - 1];
// Determine the next role (opposite of the last message)
const nextRole: Role = lastMessage.role === 'user' ? 'assistant' : 'user';
// Create the new message with SummarizationRequestedContent
return {
id: generateId(),
role: nextRole,
created: Math.floor(Date.now() / 1000),
content: [
{
type: 'summarizationRequested',
msg: requestMessage,
},
],
sendToLLM: false,
display: true,
};
}
+1 -2
View File
@@ -312,7 +312,7 @@ export function useMessageStream({
...extraMetadataRef.current.headers, ...extraMetadataRef.current.headers,
}, },
body: JSON.stringify({ body: JSON.stringify({
messages: requestMessages, messages: filteredMessages,
...extraMetadataRef.current.body, ...extraMetadataRef.current.body,
}), }),
signal: abortController.signal, signal: abortController.signal,
@@ -439,7 +439,6 @@ export function useMessageStream({
event?.preventDefault?.(); event?.preventDefault?.();
if (!input.trim()) return; if (!input.trim()) return;
console.log('handleSubmit called with input:', input);
await append(input); await append(input);
setInput(''); setInput('');
}, },
+9 -3
View File
@@ -78,21 +78,27 @@ export interface ContextLengthExceededContent {
msg: string; msg: string;
} }
export interface SummarizationRequestedContent {
type: 'summarizationRequested';
msg: string;
}
export type MessageContent = export type MessageContent =
| TextContent | TextContent
| ImageContent | ImageContent
| ToolRequestMessageContent | ToolRequestMessageContent
| ToolResponseMessageContent | ToolResponseMessageContent
| ToolConfirmationRequestMessageContent | ToolConfirmationRequestMessageContent
| ContextLengthExceededContent; | ContextLengthExceededContent
| SummarizationRequestedContent;
export interface Message { export interface Message {
id?: string; id?: string;
role: Role; role: Role;
created: number; created: number;
content: MessageContent[]; content: MessageContent[];
display: boolean; display?: boolean;
sendToLLM: boolean; sendToLLM?: boolean;
} }
// Helper functions to create messages // Helper functions to create messages