context_management: handle summarization in UI (#2377)

This commit is contained in:
Lily Delalande
2025-04-30 16:55:23 -04:00
committed by GitHub
parent cb6fca2e1d
commit 67aa019489
17 changed files with 1395 additions and 127 deletions

View File

@@ -3,8 +3,16 @@ use goose::agents::extension::ToolInfo;
use goose::agents::ExtensionConfig;
use goose::config::permission::PermissionLevel;
use goose::config::ExtensionEntry;
use goose::message::{
ContextLengthExceeded, FrontendToolRequest, Message, MessageContent, RedactedThinkingContent,
ThinkingContent, ToolConfirmationRequest, ToolRequest, ToolResponse,
};
use goose::permission::permission_confirmation::PrincipalType;
use goose::providers::base::{ConfigKey, ModelInfo, ProviderMetadata};
use mcp_core::content::{Annotations, Content, EmbeddedResource, ImageContent, TextContent};
use mcp_core::handler::ToolResultSchema;
use mcp_core::resource::ResourceContents;
use mcp_core::role::Role;
use mcp_core::tool::{Tool, ToolAnnotations};
use utoipa::OpenApi;
@@ -25,6 +33,7 @@ use utoipa::OpenApi;
super::routes::config_management::upsert_permissions,
super::routes::agent::get_tools,
super::routes::reply::confirm_permission,
super::routes::context::manage_context, // Added this path
),
components(schemas(
super::routes::config_management::UpsertConfigQuery,
@@ -37,6 +46,25 @@ use utoipa::OpenApi;
super::routes::config_management::ToolPermission,
super::routes::config_management::UpsertPermissionsQuery,
super::routes::reply::PermissionConfirmationRequest,
super::routes::context::ContextManageRequest,
super::routes::context::ContextManageResponse,
Message,
MessageContent,
Content,
EmbeddedResource,
ImageContent,
Annotations,
TextContent,
ToolResponse,
ToolRequest,
ToolResultSchema,
ToolConfirmationRequest,
ThinkingContent,
RedactedThinkingContent,
FrontendToolRequest,
ResourceContents,
ContextLengthExceeded,
Role,
ProviderMetadata,
ExtensionEntry,
ExtensionConfig,

View File

@@ -9,23 +9,43 @@ use axum::{
use goose::message::Message;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use utoipa::ToSchema;
// Direct message serialization for context mgmt request
#[derive(Debug, Deserialize)]
/// Request payload for context management operations
#[derive(Debug, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ContextManageRequest {
messages: Vec<Message>,
manage_action: String,
/// Collection of messages to be managed
pub messages: Vec<Message>,
/// Operation to perform: "truncation" or "summarize"
pub manage_action: String,
}
// Direct message serialization for context mgmt request
#[derive(Debug, Serialize)]
/// Response from context management operations
#[derive(Debug, Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ContextManageResponse {
messages: Vec<Message>,
token_counts: Vec<usize>,
/// Processed messages after the operation
pub messages: Vec<Message>,
/// Token counts for each processed message
pub token_counts: Vec<usize>,
}
#[utoipa::path(
post,
path = "/context/manage",
request_body = ContextManageRequest,
responses(
(status = 200, description = "Context managed successfully", body = ContextManageResponse),
(status = 401, description = "Unauthorized - Invalid or missing API key"),
(status = 412, description = "Precondition failed - Agent not available"),
(status = 500, description = "Internal server error")
),
security(
("api_key" = [])
),
tag = "Context Management"
)]
async fn manage_context(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
@@ -40,7 +60,8 @@ async fn manage_context(
let mut processed_messages: Vec<Message> = vec![];
let mut token_counts: Vec<usize> = vec![];
if request.manage_action == "trunction" {
if request.manage_action == "truncation" {
(processed_messages, token_counts) = agent
.truncate_context(&request.messages)
.await

View File

@@ -16,14 +16,17 @@ use mcp_core::role::Role;
use mcp_core::tool::ToolCall;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use utoipa::ToSchema;
mod tool_result_serde;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[derive(ToSchema)]
pub struct ToolRequest {
pub id: String,
#[serde(with = "tool_result_serde")]
#[schema(value_type = Object)]
pub tool_call: ToolResult<ToolCall>,
}
@@ -45,14 +48,17 @@ impl ToolRequest {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[derive(ToSchema)]
pub struct ToolResponse {
pub id: String,
#[serde(with = "tool_result_serde")]
#[schema(value_type = Object)]
pub tool_result: ToolResult<Vec<Content>>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[derive(ToSchema)]
pub struct ToolConfirmationRequest {
pub id: String,
pub tool_name: String,
@@ -60,31 +66,33 @@ pub struct ToolConfirmationRequest {
pub prompt: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
pub struct ThinkingContent {
pub thinking: String,
pub signature: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
pub struct RedactedThinkingContent {
pub data: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[derive(ToSchema)]
pub struct FrontendToolRequest {
pub id: String,
#[serde(with = "tool_result_serde")]
#[schema(value_type = Object)]
pub tool_call: ToolResult<ToolCall>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
pub struct ContextLengthExceeded {
pub msg: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
/// Content passed inside a message, which can be both simple content and tool content
#[serde(tag = "type", rename_all = "camelCase")]
pub enum MessageContent {
@@ -273,7 +281,7 @@ impl From<PromptMessage> for Message {
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[derive(ToSchema, Debug, Clone, PartialEq, Serialize, Deserialize)]
/// A message to or from an LLM
#[serde(rename_all = "camelCase")]
pub struct Message {

View File

@@ -5,8 +5,9 @@ use super::role::Role;
use crate::resource::ResourceContents;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[derive(ToSchema, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Annotations {
#[serde(skip_serializing_if = "Option::is_none")]
@@ -14,6 +15,8 @@ pub struct Annotations {
#[serde(skip_serializing_if = "Option::is_none")]
pub priority: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schema(value_type = String, format = "date-time", example = "2023-01-01T00:00:00Z")]
// for openapi
pub timestamp: Option<DateTime<Utc>>,
}
@@ -33,7 +36,7 @@ impl Annotations {
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[derive(ToSchema, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextContent {
pub text: String,
@@ -41,7 +44,7 @@ pub struct TextContent {
pub annotations: Option<Annotations>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[derive(ToSchema, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImageContent {
pub data: String,
@@ -50,7 +53,7 @@ pub struct ImageContent {
pub annotations: Option<Annotations>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[derive(ToSchema, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EmbeddedResource {
pub resource: ResourceContents,
@@ -67,7 +70,7 @@ impl EmbeddedResource {
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[derive(ToSchema, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum Content {
Text(TextContent),

View File

@@ -1,8 +1,11 @@
use async_trait::async_trait;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[allow(unused_imports)] // this is used in schema below
use serde_json::json;
use serde_json::Value;
use thiserror::Error;
use utoipa::ToSchema;
#[non_exhaustive]
#[derive(Error, Debug, Clone, Deserialize, Serialize, PartialEq)]
@@ -19,6 +22,18 @@ pub enum ToolError {
pub type ToolResult<T> = std::result::Result<T, ToolError>;
// Define schema manually without generics issues
#[derive(ToSchema)]
#[schema(example = json!({"success": true, "data": {}}))]
pub struct ToolResultSchema {
#[schema(example = "Operation completed successfully")]
pub message: Option<String>,
#[schema(example = true)]
pub success: bool,
#[schema(value_type = Object)]
pub data: Option<serde_json::Value>,
}
#[derive(Error, Debug)]
pub enum ResourceError {
#[error("Execution failed: {0}")]

View File

@@ -1,10 +1,10 @@
use crate::content::Annotations;
/// Resources that servers provide to clients
use anyhow::{anyhow, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::content::Annotations;
use utoipa::ToSchema;
const EPSILON: f32 = 1e-6; // Tolerance for floating point comparison
@@ -28,6 +28,7 @@ pub struct Resource {
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(rename_all = "camelCase", untagged)]
#[derive(ToSchema)]
pub enum ResourceContents {
TextResourceContents {
uri: String,

View File

@@ -1,7 +1,8 @@
/// Roles to describe the origin/ownership of content
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum Role {
User,

View File

@@ -10,7 +10,7 @@
"license": {
"name": "Apache-2.0"
},
"version": "1.0.20"
"version": "1.0.21"
},
"paths": {
"/agent/tools": {
@@ -408,10 +408,76 @@
}
}
}
},
"/context/manage": {
"post": {
"tags": [
"Context Management"
],
"operationId": "manage_context",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ContextManageRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Context managed successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ContextManageResponse"
}
}
}
},
"401": {
"description": "Unauthorized - Invalid or missing API key"
},
"412": {
"description": "Precondition failed - Agent not available"
},
"500": {
"description": "Internal server error"
}
},
"security": [
{
"api_key": []
}
]
}
}
},
"components": {
"schemas": {
"Annotations": {
"type": "object",
"properties": {
"audience": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Role"
},
"nullable": true
},
"priority": {
"type": "number",
"format": "float",
"nullable": true
},
"timestamp": {
"type": "string",
"format": "date-time",
"example": "2023-01-01T00:00:00Z"
}
}
},
"ConfigKey": {
"type": "object",
"required": [
@@ -462,6 +528,152 @@
}
}
},
"Content": {
"oneOf": [
{
"allOf": [
{
"$ref": "#/components/schemas/TextContent"
},
{
"type": "object",
"required": [
"type"
],
"properties": {
"type": {
"type": "string",
"enum": [
"text"
]
}
}
}
]
},
{
"allOf": [
{
"$ref": "#/components/schemas/ImageContent"
},
{
"type": "object",
"required": [
"type"
],
"properties": {
"type": {
"type": "string",
"enum": [
"image"
]
}
}
}
]
},
{
"allOf": [
{
"$ref": "#/components/schemas/EmbeddedResource"
},
{
"type": "object",
"required": [
"type"
],
"properties": {
"type": {
"type": "string",
"enum": [
"resource"
]
}
}
}
]
}
],
"discriminator": {
"propertyName": "type"
}
},
"ContextLengthExceeded": {
"type": "object",
"required": [
"msg"
],
"properties": {
"msg": {
"type": "string"
}
}
},
"ContextManageRequest": {
"type": "object",
"description": "Request payload for context management operations",
"required": [
"messages",
"manageAction"
],
"properties": {
"manageAction": {
"type": "string",
"description": "Operation to perform: \"truncation\" or \"summarize\""
},
"messages": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Message"
},
"description": "Collection of messages to be managed"
}
}
},
"ContextManageResponse": {
"type": "object",
"description": "Response from context management operations",
"required": [
"messages",
"tokenCounts"
],
"properties": {
"messages": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Message"
},
"description": "Processed messages after the operation"
},
"tokenCounts": {
"type": "array",
"items": {
"type": "integer",
"minimum": 0
},
"description": "Token counts for each processed message"
}
}
},
"EmbeddedResource": {
"type": "object",
"required": [
"resource"
],
"properties": {
"annotations": {
"allOf": [
{
"$ref": "#/components/schemas/Annotations"
}
],
"nullable": true
},
"resource": {
"$ref": "#/components/schemas/ResourceContents"
}
}
},
"Envs": {
"type": "object",
"additionalProperties": {
@@ -704,6 +916,265 @@
}
}
},
"FrontendToolRequest": {
"type": "object",
"required": [
"id",
"toolCall"
],
"properties": {
"id": {
"type": "string"
},
"toolCall": {
"type": "object"
}
}
},
"ImageContent": {
"type": "object",
"required": [
"data",
"mimeType"
],
"properties": {
"annotations": {
"allOf": [
{
"$ref": "#/components/schemas/Annotations"
}
],
"nullable": true
},
"data": {
"type": "string"
},
"mimeType": {
"type": "string"
}
}
},
"Message": {
"type": "object",
"description": "A message to or from an LLM",
"required": [
"role",
"created",
"content"
],
"properties": {
"content": {
"type": "array",
"items": {
"$ref": "#/components/schemas/MessageContent"
}
},
"created": {
"type": "integer",
"format": "int64"
},
"role": {
"$ref": "#/components/schemas/Role"
}
}
},
"MessageContent": {
"oneOf": [
{
"allOf": [
{
"$ref": "#/components/schemas/TextContent"
},
{
"type": "object",
"required": [
"type"
],
"properties": {
"type": {
"type": "string",
"enum": [
"text"
]
}
}
}
]
},
{
"allOf": [
{
"$ref": "#/components/schemas/ImageContent"
},
{
"type": "object",
"required": [
"type"
],
"properties": {
"type": {
"type": "string",
"enum": [
"image"
]
}
}
}
]
},
{
"allOf": [
{
"$ref": "#/components/schemas/ToolRequest"
},
{
"type": "object",
"required": [
"type"
],
"properties": {
"type": {
"type": "string",
"enum": [
"toolRequest"
]
}
}
}
]
},
{
"allOf": [
{
"$ref": "#/components/schemas/ToolResponse"
},
{
"type": "object",
"required": [
"type"
],
"properties": {
"type": {
"type": "string",
"enum": [
"toolResponse"
]
}
}
}
]
},
{
"allOf": [
{
"$ref": "#/components/schemas/ToolConfirmationRequest"
},
{
"type": "object",
"required": [
"type"
],
"properties": {
"type": {
"type": "string",
"enum": [
"toolConfirmationRequest"
]
}
}
}
]
},
{
"allOf": [
{
"$ref": "#/components/schemas/FrontendToolRequest"
},
{
"type": "object",
"required": [
"type"
],
"properties": {
"type": {
"type": "string",
"enum": [
"frontendToolRequest"
]
}
}
}
]
},
{
"allOf": [
{
"$ref": "#/components/schemas/ThinkingContent"
},
{
"type": "object",
"required": [
"type"
],
"properties": {
"type": {
"type": "string",
"enum": [
"thinking"
]
}
}
}
]
},
{
"allOf": [
{
"$ref": "#/components/schemas/RedactedThinkingContent"
},
{
"type": "object",
"required": [
"type"
],
"properties": {
"type": {
"type": "string",
"enum": [
"redactedThinking"
]
}
}
}
]
},
{
"allOf": [
{
"$ref": "#/components/schemas/ContextLengthExceeded"
},
{
"type": "object",
"required": [
"type"
],
"properties": {
"type": {
"type": "string",
"enum": [
"contextLengthExceeded"
]
}
}
}
]
}
],
"description": "Content passed inside a message, which can be both simple content and tool content",
"discriminator": {
"propertyName": "type"
}
},
"ModelInfo": {
"type": "object",
"description": "Information about a model's capabilities",
@@ -841,6 +1312,100 @@
}
}
},
"RedactedThinkingContent": {
"type": "object",
"required": [
"data"
],
"properties": {
"data": {
"type": "string"
}
}
},
"ResourceContents": {
"oneOf": [
{
"type": "object",
"required": [
"uri",
"text"
],
"properties": {
"mime_type": {
"type": "string",
"nullable": true
},
"text": {
"type": "string"
},
"uri": {
"type": "string"
}
}
},
{
"type": "object",
"required": [
"uri",
"blob"
],
"properties": {
"blob": {
"type": "string"
},
"mime_type": {
"type": "string",
"nullable": true
},
"uri": {
"type": "string"
}
}
}
]
},
"Role": {
"type": "string",
"enum": [
"user",
"assistant"
]
},
"TextContent": {
"type": "object",
"required": [
"text"
],
"properties": {
"annotations": {
"allOf": [
{
"$ref": "#/components/schemas/Annotations"
}
],
"nullable": true
},
"text": {
"type": "string"
}
}
},
"ThinkingContent": {
"type": "object",
"required": [
"thinking",
"signature"
],
"properties": {
"signature": {
"type": "string"
},
"thinking": {
"type": "string"
}
}
},
"Tool": {
"type": "object",
"description": "A tool that can be used by a model.",
@@ -898,6 +1463,27 @@
}
}
},
"ToolConfirmationRequest": {
"type": "object",
"required": [
"id",
"toolName",
"arguments"
],
"properties": {
"arguments": {},
"id": {
"type": "string"
},
"prompt": {
"type": "string",
"nullable": true
},
"toolName": {
"type": "string"
}
}
},
"ToolInfo": {
"type": "object",
"description": "Information about the tool used for building prompts",
@@ -945,6 +1531,61 @@
}
}
},
"ToolRequest": {
"type": "object",
"required": [
"id",
"toolCall"
],
"properties": {
"id": {
"type": "string"
},
"toolCall": {
"type": "object"
}
}
},
"ToolResponse": {
"type": "object",
"required": [
"id",
"toolResult"
],
"properties": {
"id": {
"type": "string"
},
"toolResult": {
"type": "object"
}
}
},
"ToolResultSchema": {
"type": "object",
"required": [
"success",
"data"
],
"properties": {
"data": {
"type": "object"
},
"message": {
"type": "string",
"example": "Operation completed successfully",
"nullable": true
},
"success": {
"type": "boolean",
"example": true
}
},
"example": {
"data": {},
"success": true
}
},
"UpsertConfigQuery": {
"type": "object",
"required": [

View File

@@ -1,7 +1,7 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Options as ClientOptions, TDataShape, Client } from '@hey-api/client-fetch';
import type { GetToolsData, GetToolsResponse, ReadAllConfigData, ReadAllConfigResponse, BackupConfigData, BackupConfigResponse, GetExtensionsData, GetExtensionsResponse, AddExtensionData, AddExtensionResponse, RemoveExtensionData, RemoveExtensionResponse, InitConfigData, InitConfigResponse, UpsertPermissionsData, UpsertPermissionsResponse, ProvidersData, ProvidersResponse2, ReadConfigData, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse, ConfirmPermissionData } from './types.gen';
import type { GetToolsData, GetToolsResponse, ReadAllConfigData, ReadAllConfigResponse, BackupConfigData, BackupConfigResponse, GetExtensionsData, GetExtensionsResponse, AddExtensionData, AddExtensionResponse, RemoveExtensionData, RemoveExtensionResponse, InitConfigData, InitConfigResponse, UpsertPermissionsData, UpsertPermissionsResponse, ProvidersData, ProvidersResponse2, ReadConfigData, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse, ConfirmPermissionData, ManageContextData, ManageContextResponse } from './types.gen';
import { client as _heyApiClient } from './client.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = ClientOptions<TData, ThrowOnError> & {
@@ -132,3 +132,14 @@ export const confirmPermission = <ThrowOnError extends boolean = false>(options:
}
});
};
export const manageContext = <ThrowOnError extends boolean = false>(options: Options<ManageContextData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<ManageContextResponse, unknown, ThrowOnError>({
url: '/context/manage',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};

View File

@@ -1,5 +1,11 @@
// This file is auto-generated by @hey-api/openapi-ts
export type Annotations = {
audience?: Array<Role> | null;
priority?: number | null;
timestamp?: string;
};
export type ConfigKey = {
default?: string | null;
name: string;
@@ -16,6 +22,51 @@ export type ConfigResponse = {
config: {};
};
export type Content = (TextContent & {
type: 'text';
}) | (ImageContent & {
type: 'image';
}) | (EmbeddedResource & {
type: 'resource';
});
export type ContextLengthExceeded = {
msg: string;
};
/**
* Request payload for context management operations
*/
export type ContextManageRequest = {
/**
* Operation to perform: "truncation" or "summarize"
*/
manageAction: string;
/**
* Collection of messages to be managed
*/
messages: Array<Message>;
};
/**
* Response from context management operations
*/
export type ContextManageResponse = {
/**
* Processed messages after the operation
*/
messages: Array<Message>;
/**
* Token counts for each processed message
*/
tokenCounts: Array<number>;
};
export type EmbeddedResource = {
annotations?: Annotations | null;
resource: ResourceContents;
};
export type Envs = {
[key: string]: string;
};
@@ -102,6 +153,51 @@ export type ExtensionResponse = {
extensions: Array<ExtensionEntry>;
};
export type FrontendToolRequest = {
id: string;
toolCall: {
[key: string]: unknown;
};
};
export type ImageContent = {
annotations?: Annotations | null;
data: string;
mimeType: string;
};
/**
* A message to or from an LLM
*/
export type Message = {
content: Array<MessageContent>;
created: number;
role: Role;
};
/**
* Content passed inside a message, which can be both simple content and tool content
*/
export type MessageContent = (TextContent & {
type: 'text';
}) | (ImageContent & {
type: 'image';
}) | (ToolRequest & {
type: 'toolRequest';
}) | (ToolResponse & {
type: 'toolResponse';
}) | (ToolConfirmationRequest & {
type: 'toolConfirmationRequest';
}) | (FrontendToolRequest & {
type: 'frontendToolRequest';
}) | (ThinkingContent & {
type: 'thinking';
}) | (RedactedThinkingContent & {
type: 'redactedThinking';
}) | (ContextLengthExceeded & {
type: 'contextLengthExceeded';
});
/**
* Information about a model's capabilities
*/
@@ -180,6 +276,32 @@ export type ProvidersResponse = {
providers: Array<ProviderDetails>;
};
export type RedactedThinkingContent = {
data: string;
};
export type ResourceContents = {
mime_type?: string | null;
text: string;
uri: string;
} | {
blob: string;
mime_type?: string | null;
uri: string;
};
export type Role = 'user' | 'assistant';
export type TextContent = {
annotations?: Annotations | null;
text: string;
};
export type ThinkingContent = {
signature: string;
thinking: string;
};
/**
* A tool that can be used by a model.
*/
@@ -249,6 +371,13 @@ export type ToolAnnotations = {
title?: string | null;
};
export type ToolConfirmationRequest = {
arguments: unknown;
id: string;
prompt?: string | null;
toolName: string;
};
/**
* Information about the tool used for building prompts
*/
@@ -267,6 +396,28 @@ export type ToolPermission = {
tool_name: string;
};
export type ToolRequest = {
id: string;
toolCall: {
[key: string]: unknown;
};
};
export type ToolResponse = {
id: string;
toolResult: {
[key: string]: unknown;
};
};
export type ToolResultSchema = {
data: {
[key: string]: unknown;
};
message?: string | null;
success: boolean;
};
export type UpsertConfigQuery = {
is_secret: boolean;
key: string;
@@ -593,6 +744,37 @@ export type ConfirmPermissionResponses = {
200: unknown;
};
export type ManageContextData = {
body: ContextManageRequest;
path?: never;
query?: never;
url: '/context/manage';
};
export type ManageContextErrors = {
/**
* Unauthorized - Invalid or missing API key
*/
401: unknown;
/**
* Precondition failed - Agent not available
*/
412: unknown;
/**
* Internal server error
*/
500: unknown;
};
export type ManageContextResponses = {
/**
* Context managed successfully
*/
200: ContextManageResponse;
};
export type ManageContextResponse = ManageContextResponses[keyof ManageContextResponses];
export type ClientOptions = {
baseUrl: `${string}://${string}` | (string & {});
};

View File

@@ -16,11 +16,12 @@ import { createRecipe } from '../recipe';
import { AgentHeader } from './AgentHeader';
import LayingEggLoader from './LayingEggLoader';
import { fetchSessionDetails } from '../sessions';
// import { configureRecipeExtensions } from '../utils/recipeExtensions';
import 'react-toastify/dist/ReactToastify.css';
import { useMessageStream } from '../hooks/useMessageStream';
import { SessionSummaryModal } from './context_management/SessionSummaryModal';
import { Recipe } from '../recipe';
import { ContextManagerProvider, useChatContextManager } from './context_management/ContextManager';
import { ContextLengthExceededHandler } from './context_management/ContextLengthExceededHandler';
import {
Message,
createUserMessage,
@@ -30,7 +31,6 @@ import {
ToolResponseMessageContent,
ToolConfirmationRequestMessageContent,
} from '../types/message';
import { manageContext } from './context_management';
export interface ChatType {
id: string;
@@ -63,6 +63,30 @@ export default function ChatView({
setView: (view: View, viewOptions?: ViewOptions) => void;
setIsGoosehintsModalOpen: (isOpen: boolean) => void;
}) {
return (
<ContextManagerProvider>
<ChatContent
chat={chat}
setChat={setChat}
setView={setView}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
/>
</ContextManagerProvider>
);
}
function ChatContent({
chat,
setChat,
setView,
setIsGoosehintsModalOpen,
}: {
chat: ChatType;
setChat: (chat: ChatType) => void;
setView: (view: View, viewOptions?: ViewOptions) => void;
setIsGoosehintsModalOpen: (isOpen: boolean) => void;
}) {
// Disabled askAi calls to save costs
// const [messageMetadata, setMessageMetadata] = useState<Record<string, string[]>>({});
const [hasMessages, setHasMessages] = useState(false);
@@ -72,15 +96,24 @@ export default function ChatView({
const [sessionTokenCount, setSessionTokenCount] = useState<number>(0);
const scrollRef = useRef<ScrollAreaHandle>(null);
const [isSummaryModalOpen, setIsSummaryModalOpen] = useState(false);
const [summaryContent, setSummaryContent] = useState('');
const [summarizedThread, setSummarizedThread] = useState<Message[]>([]);
const {
summaryContent,
summarizedThread,
isSummaryModalOpen,
resetMessagesWithSummary,
closeSummaryModal,
updateSummary,
hasContextLengthExceededContent,
} = useChatContextManager();
// Add this function to handle opening the summary modal with content
const handleViewSummary = (summary: string) => {
setSummaryContent(summary);
setIsSummaryModalOpen(true);
};
useEffect(() => {
// Log all messages when the component first mounts
console.log('Initial messages when resuming session:', chat.messages);
window.electron.logInfo(
'Initial messages when resuming session: ' + JSON.stringify(chat.messages, null, 2)
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Empty dependency array means this runs once on mount;
// Get recipeConfig directly from appConfig
const recipeConfig = window.appConfig.get('recipeConfig') as Recipe | null;
@@ -103,6 +136,12 @@ export default function ChatView({
onFinish: async (_message, _reason) => {
window.electron.stopPowerSaveBlocker();
setTimeout(() => {
if (scrollRef.current?.scrollToBottom) {
scrollRef.current.scrollToBottom();
}
}, 300);
// Disabled askAi calls to save costs
// const messageText = getTextContent(message);
// const fetchResponses = await askAi(messageText);
@@ -118,11 +157,6 @@ export default function ChatView({
});
}
},
onToolCall: (toolCall: string) => {
// Handle tool calls if needed
console.log('Tool call received:', toolCall);
// Implement tool call handling logic here
},
});
// Listen for make-agent-from-chat event
@@ -201,12 +235,28 @@ export default function ChatView({
window.electron.startPowerSaveBlocker();
const customEvent = e as CustomEvent;
const content = customEvent.detail?.value || '';
if (content.trim()) {
setLastInteractionTime(Date.now());
if (process.env.ALPHA && summarizedThread.length > 0) {
// First reset the messages with the summary
resetMessagesWithSummary(messages, setMessages);
// Then append the new user message
setTimeout(() => {
append(createUserMessage(content));
if (scrollRef.current?.scrollToBottom) {
scrollRef.current.scrollToBottom();
}
}, 150); // Small delay to ensure state updates properly
} else {
// Normal flow - just append the message
append(createUserMessage(content));
if (scrollRef.current?.scrollToBottom) {
scrollRef.current.scrollToBottom();
}
}
}
};
@@ -300,55 +350,12 @@ export default function ChatView({
}
};
// Add this function to ChatView.tsx to detect if a message contains ContextLengthExceededContent
const hasContextLengthExceededContent = (message: Message): boolean => {
return message.content.some((content) => content.type === 'contextLengthExceeded');
};
const handleContextLengthExceeded = async () => {
// If we already have a summary, use that
if (summaryContent) {
return summaryContent;
}
// Otherwise, generate a summary
const response = await manageContext({ messages: messages, manageAction: 'summarize' });
setSummarizedThread(response.messages);
return response.messages[0].text;
};
const SummarizedNotification = ({
onViewSummary,
}: {
onViewSummary: (summaryContent: string) => void;
}) => {
const handleViewSummary = async () => {
// Await the result to get a string
const summary = summaryContent || (await handleContextLengthExceeded());
onViewSummary(summary); // Now always passing a string
};
return (
<div className="flex flex-col items-start mt-1 pl-4">
<span className="text-xs text-gray-400 italic">Session summarized</span>
<button
onClick={handleViewSummary}
className="text-xs text-textStandard cursor-pointer hover:text-textSubtle transition-colors mt-1"
>
View or edit summary
</button>
</div>
);
};
// Filter out standalone tool response messages for rendering
// They will be shown as part of the tool invocation in the assistant message
const filteredMessages = messages.filter((message) => {
// TODO: use this summarized thread in the chat window
if (summarizedThread.length > 0) {
// we have a summarized thread
console.log('summarized thread has been created --', summarizedThread);
}
// Only filter out when display is explicitly false
if (message.display === false) return false;
// Keep all assistant messages and user messages that aren't just tool responses
if (message.role === 'assistant') return true;
@@ -440,8 +447,10 @@ export default function ChatView({
<>
{/* Only render GooseMessage if it's not a CLE message (and we are not in alpha mode) */}
{process.env.ALPHA && hasContextLengthExceededContent(message) ? (
// Render the summarized notification for CLE messages only in alpha mode
<SummarizedNotification onViewSummary={handleViewSummary} />
<ContextLengthExceededHandler
messages={messages}
messageId={message.id ?? message.created.toString()}
/>
) : (
<GooseMessage
messageHistoryIndex={chat?.messageHistoryIndex}
@@ -502,11 +511,10 @@ export default function ChatView({
{process.env.ALPHA && (
<SessionSummaryModal
isOpen={isSummaryModalOpen}
onClose={() => setIsSummaryModalOpen(false)}
onClose={closeSummaryModal}
onSave={(editedContent) => {
console.log('Saving summary...');
setSummaryContent(editedContent);
setIsSummaryModalOpen(false);
updateSummary(editedContent);
closeSummaryModal();
}}
summaryContent={summaryContent}
/>

View File

@@ -0,0 +1,77 @@
import React, { useState, useRef, useEffect } from 'react';
import { Message } from '../../types/message';
import { useChatContextManager } from './ContextManager';
interface ContextLengthExceededHandlerProps {
messages: Message[];
messageId: string;
}
export const ContextLengthExceededHandler: React.FC<ContextLengthExceededHandlerProps> = ({
messages,
messageId,
}) => {
const { fetchSummary, summaryContent, isLoadingSummary, errorLoadingSummary, openSummaryModal } =
useChatContextManager();
const [hasFetchStarted, setHasFetchStarted] = useState(false);
// Find the relevant message to check if it's the latest
const isCurrentMessageLatest =
messageId === messages[messages.length - 1].id ||
messageId === messages[messages.length - 1].created.toString();
// Only allow interaction for the most recent context length exceeded event
const shouldAllowSummaryInteraction = isCurrentMessageLatest;
// Use a ref to track if we've started the fetch
const fetchStartedRef = useRef(false);
useEffect(() => {
// Automatically fetch summary if conditions are met
if (
!summaryContent &&
!hasFetchStarted &&
shouldAllowSummaryInteraction &&
!fetchStartedRef.current
) {
setHasFetchStarted(true);
fetchStartedRef.current = true;
fetchSummary(messages);
}
}, [fetchSummary, hasFetchStarted, messages, shouldAllowSummaryInteraction, summaryContent]);
// Handle retry
const handleRetry = () => {
if (!shouldAllowSummaryInteraction) return;
fetchSummary(messages);
};
// Render the notification UI
return (
<div className="flex flex-col items-start mt-1 pl-4">
{isLoadingSummary && shouldAllowSummaryInteraction ? (
// Only show loading indicator during loading state
<div className="flex items-center text-xs text-gray-400">
<span className="mr-2">Preparing summary...</span>
<span className="animate-spin h-3 w-3 border-2 border-gray-400 rounded-full border-t-transparent"></span>
</div>
) : (
// Show different UI based on whether it's already handled
<>
<span className="text-xs text-gray-400 italic">{'Session summarized'}</span>
{/* Only show the button if its last message */}
{shouldAllowSummaryInteraction && (
<button
onClick={() => (errorLoadingSummary ? handleRetry() : openSummaryModal())}
className="text-xs text-textStandard hover:text-textSubtle transition-colors mt-1 flex items-center"
>
{errorLoadingSummary ? 'Retry loading summary' : 'View or edit summary'}
</button>
)}
</>
)}
</div>
);
};

View File

@@ -0,0 +1,170 @@
import React, { createContext, useContext, useState } from 'react';
import { Message } from '../../types/message';
import { manageContextFromBackend, convertApiMessageToFrontendMessage } from './index';
// Define the context management interface
interface ContextManagerState {
summaryContent: string;
summarizedThread: Message[];
isSummaryModalOpen: boolean;
isLoadingSummary: boolean;
errorLoadingSummary: boolean;
}
interface ContextManagerActions {
fetchSummary: (messages: Message[]) => Promise<void>;
updateSummary: (newSummaryContent: string) => void;
resetMessagesWithSummary: (
messages: Message[],
setMessages: (messages: Message[]) => void
) => void;
openSummaryModal: () => void;
closeSummaryModal: () => void;
hasContextLengthExceededContent: (message: Message) => boolean;
}
// Create the context
const ContextManagerContext = createContext<
(ContextManagerState & ContextManagerActions) | undefined
>(undefined);
// Create the provider component
export const ContextManagerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [summaryContent, setSummaryContent] = useState<string>('');
const [summarizedThread, setSummarizedThread] = useState<Message[]>([]);
const [isSummaryModalOpen, setIsSummaryModalOpen] = useState<boolean>(false);
const [isLoadingSummary, setIsLoadingSummary] = useState<boolean>(false);
const [errorLoadingSummary, setErrorLoadingSummary] = useState<boolean>(false);
const fetchSummary = async (messages: Message[]) => {
setIsLoadingSummary(true);
setErrorLoadingSummary(false);
try {
const response = await manageContextFromBackend({
messages: messages,
manageAction: 'summarize',
});
// Convert API messages to frontend messages
const convertedMessages = response.messages.map((apiMessage) =>
convertApiMessageToFrontendMessage(apiMessage)
);
// 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) => {
// Update the summary content
setSummaryContent(newSummaryContent);
// Update the thread if it exists
if (summarizedThread.length > 0) {
// Create a deep copy of the thread
const updatedThread = [...summarizedThread];
// Create a copy of the first message
const firstMessage = { ...updatedThread[0] };
// Create a copy of the content array
const updatedContent = [...firstMessage.content];
// Update the summary text in the first content item
if (updatedContent[0] && updatedContent[0].type === 'text') {
updatedContent[0] = {
...updatedContent[0],
text: newSummaryContent,
};
}
// Update the message with the new content
firstMessage.content = updatedContent;
updatedThread[0] = firstMessage;
// Update the thread
setSummarizedThread(updatedThread);
}
};
const resetMessagesWithSummary = (
messages: Message[],
setMessages: (messages: Message[]) => void
) => {
// Update summarizedThread with metadata
const updatedSummarizedThread = summarizedThread.map((msg) => ({
...msg,
display: false,
sendToLLM: true,
}));
// Update list of messages with other metadata
const updatedMessages = messages.map((msg) => ({
...msg,
display: true,
sendToLLM: false,
}));
// Make a copy that combines both
const newMessages = [...updatedMessages, ...updatedSummarizedThread];
// Update the messages state
setMessages(newMessages);
// Clear the summarized thread and content
setSummarizedThread([]);
setSummaryContent('');
};
const hasContextLengthExceededContent = (message: Message): boolean => {
return message.content.some((content) => content.type === 'contextLengthExceeded');
};
const openSummaryModal = () => {
setIsSummaryModalOpen(true);
};
const closeSummaryModal = () => {
setIsSummaryModalOpen(false);
};
const value = {
// State
summaryContent,
summarizedThread,
isSummaryModalOpen,
isLoadingSummary,
errorLoadingSummary,
// Actions
fetchSummary,
updateSummary,
resetMessagesWithSummary,
openSummaryModal,
closeSummaryModal,
hasContextLengthExceededContent,
};
return <ContextManagerContext.Provider value={value}>{children}</ContextManagerContext.Provider>;
};
// Create a hook to use the context
export const useChatContextManager = () => {
const context = useContext(ContextManagerContext);
if (context === undefined) {
throw new Error('useContextManager must be used within a ContextManagerProvider');
}
return context;
};

View File

@@ -1,37 +1,119 @@
import { Message } from '../../types/message';
import { getApiUrl, getSecretKey } from '../../config';
import {
Message as FrontendMessage,
Content as FrontendContent,
MessageContent as FrontendMessageContent,
ToolCallResult,
ToolCall,
} from '../../types/message';
import {
ContextManageRequest,
ContextManageResponse,
manageContext,
Message as ApiMessage,
MessageContent as ApiMessageContent,
} from '../../api';
import { generateId } from 'ai';
export async function manageContext({
export async function manageContextFromBackend({
messages,
manageAction,
}: {
messages: Message[];
manageAction: 'trunction' | 'summarize';
}) {
const response = await fetch(getApiUrl('/context/manage'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Secret-Key': getSecretKey(),
},
body: JSON.stringify({
messages,
manageAction,
}),
messages: FrontendMessage[];
manageAction: 'truncation' | 'summarize';
}): Promise<ContextManageResponse> {
try {
const contextManagementRequest = { manageAction, messages };
// Cast to the API-expected type
const result = await manageContext({
body: contextManagementRequest as unknown as ContextManageRequest,
});
if (!response.ok) {
if (!response.ok) {
// Get the status text or a default message
const errorText = await response.text().catch(() => 'Unknown error');
// log error with status and details
console.error(
`Context management failed: ${response.status} ${response.statusText} - ${errorText}`
);
// Check for errors in the result
if (result.error) {
throw new Error(`Context management failed: ${result.error}`);
}
// Extract the actual data from the result
if (!result.data) {
throw new Error('Context management returned no data');
}
return result.data;
} catch (error) {
console.error(`Context management failed: ${error || 'Unknown error'}`);
throw new Error(
`Context management failed: ${response.status} ${response.statusText} - ${errorText}\n\nStart a new session.`
`Context management failed: ${error || 'Unknown error'}\n\nStart a new session.`
);
}
}
const data = await response.json();
return data;
}
// Function to convert API Message to frontend Message
export function convertApiMessageToFrontendMessage(apiMessage: ApiMessage): FrontendMessage {
return {
display: false,
sendToLLM: false,
id: generateId(),
role: apiMessage.role,
created: apiMessage.created,
content: apiMessage.content
.map((apiContent) => mapApiContentToFrontendMessageContent(apiContent))
.filter((content): content is FrontendMessageContent => content !== null),
};
}
// Function to convert API MessageContent to frontend MessageContent
function mapApiContentToFrontendMessageContent(
apiContent: ApiMessageContent
): FrontendMessageContent | null {
// Handle each content type specifically based on its "type" property
if (apiContent.type === 'text') {
return {
type: 'text',
text: apiContent.text,
annotations: apiContent.annotations as Record<string, unknown> | undefined,
};
} else if (apiContent.type === 'image') {
return {
type: 'image',
data: apiContent.data,
mimeType: apiContent.mimeType,
annotations: apiContent.annotations as Record<string, unknown> | undefined,
};
} else if (apiContent.type === 'toolRequest') {
// Ensure the toolCall has the correct type structure
const toolCall = apiContent.toolCall as unknown as ToolCallResult<ToolCall>;
return {
type: 'toolRequest',
id: apiContent.id,
toolCall: toolCall,
};
} else if (apiContent.type === 'toolResponse') {
// Ensure the toolResult has the correct type structure
const toolResult = apiContent.toolResult as unknown as ToolCallResult<FrontendContent[]>;
return {
type: 'toolResponse',
id: apiContent.id,
toolResult: toolResult,
};
} else if (apiContent.type === 'toolConfirmationRequest') {
return {
type: 'toolConfirmationRequest',
id: apiContent.id,
toolName: apiContent.toolName,
arguments: apiContent.arguments as Record<string, unknown>,
prompt: apiContent.prompt === null ? undefined : apiContent.prompt,
};
} else if (apiContent.type === 'contextLengthExceeded') {
return {
type: 'contextLengthExceeded',
msg: apiContent.msg,
};
}
// For types that exist in API but not in frontend, either skip or convert
console.warn(`Skipping unsupported content type: ${apiContent.type}`);
return null;
}

View File

@@ -215,11 +215,26 @@ export function useMessageStream({
const parsedEvent = JSON.parse(data) as MessageEvent;
switch (parsedEvent.type) {
case 'Message':
case 'Message': {
// Create a new message object with the properties preserved or defaulted
const newMessage = {
...parsedEvent.message,
// Only set to true if it's undefined (preserve false values)
display:
parsedEvent.message.display === undefined
? true
: parsedEvent.message.display,
sendToLLM:
parsedEvent.message.sendToLLM === undefined
? true
: parsedEvent.message.sendToLLM,
};
// Update messages with the new message
currentMessages = [...currentMessages, parsedEvent.message];
currentMessages = [...currentMessages, newMessage];
mutate(currentMessages, false);
break;
}
case 'Error':
throw new Error(parsedEvent.error);
@@ -268,9 +283,12 @@ export function useMessageStream({
const abortController = new AbortController();
abortControllerRef.current = abortController;
// Filter out messages where sendToLLM is explicitly false
const filteredMessages = requestMessages.filter((message) => message.sendToLLM !== false);
// Log request details for debugging
console.log('Request details:', {
messages: requestMessages,
messages: filteredMessages,
body: extraMetadataRef.current.body,
});

View File

@@ -91,6 +91,8 @@ export interface Message {
role: Role;
created: number;
content: MessageContent[];
display: boolean;
sendToLLM: boolean;
}
// Helper functions to create messages