Merge branch 'main' into agent-knowledge-base

This commit is contained in:
Soumyadas15 2025-07-03 09:49:57 +05:30
commit 028f33ae34
7 changed files with 118 additions and 115 deletions

View File

@ -3,6 +3,8 @@ FROM ghcr.io/astral-sh/uv:python3.11-alpine
ENV ENV_MODE production
WORKDIR /app
RUN apk add --no-cache curl
# Install Python dependencies
COPY pyproject.toml uv.lock ./
ENV UV_LINK_MODE=copy

View File

@ -4,11 +4,12 @@ import mimetypes
from typing import Optional, Tuple
from io import BytesIO
from PIL import Image
from urllib.parse import urlparse
from agentpress.tool import ToolResult, openapi_schema, xml_schema
from sandbox.tool_base import SandboxToolsBase
from agentpress.thread_manager import ThreadManager
import json
import requests
# Add common image MIME types if mimetypes module is limited
mimetypes.add_type("image/webp", ".webp")
@ -100,17 +101,55 @@ class SandboxVisionTool(SandboxToolsBase):
print(f"[SeeImage] Failed to compress image: {str(e)}. Using original.")
return image_bytes, mime_type
def is_url(self, file_path: str) -> bool:
"""check if the file path is url"""
parsed_url = urlparse(file_path)
return parsed_url.scheme in ('http', 'https')
def download_image_from_url(self, url: str) -> Tuple[bytes, str]:
"""Download image from a URL"""
try:
headers = {
"User-Agent": "Mozilla/5.0" # Some servers block default Python
}
# HEAD request to get the image size
head_response = requests.head(url, timeout=10, headers=headers, stream=True)
head_response.raise_for_status()
# Check content length
content_length = int(head_response.headers.get('Content-Length'))
if content_length and content_length > MAX_IMAGE_SIZE:
raise Exception(f"Image is too large ({(content_length)/(1024*1024):.2f}MB) for the maximum allowed size of {MAX_IMAGE_SIZE/(1024*1024):.2f}MB")
# Download the image
response = requests.get(url, timeout=10, headers=headers, stream=True)
response.raise_for_status()
image_bytes = response.content
if len(image_bytes) > MAX_IMAGE_SIZE:
raise Exception(f"Downloaded image is too large ({(len(image_bytes))/(1024*1024):.2f}MB). Maximum allowed size of {MAX_IMAGE_SIZE/(1024*1024):.2f}MB")
# Get MIME type
mime_type = response.headers.get('Content-Type')
if not mime_type or not mime_type.startswith('image/'):
raise Exception(f"URL does not point to an image (Content-Type: {mime_type}): {url}")
return image_bytes, mime_type
except Exception as e:
return self.fail_response(f"Failed to download image from URL: {str(e)}")
@openapi_schema({
"type": "function",
"function": {
"name": "see_image",
"description": "Allows the agent to 'see' an image file located in the /workspace directory. Provide the relative path to the image. The image will be compressed before sending to reduce token usage. The image content will be made available in the next turn's context.",
"description": "Allows the agent to 'see' an image file located in the /workspace directory or from a URL. Provide either a relative path to a local image or the URL to an image. The image will be compressed before sending to reduce token usage. The image content will be made available in the next turn's context.",
"parameters": {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "The relative path to the image file within the /workspace directory (e.g., 'screenshots/image.png'). Supported formats: JPG, PNG, GIF, WEBP. Max size: 10MB."
"description": "Either a relative path to the image file within the /workspace directory (e.g., 'screenshots/image.png') or a URL to an image (e.g., 'https://example.com/image.jpg'). Supported formats: JPG, PNG, GIF, WEBP. Max size: 10MB."
}
},
"required": ["file_path"]
@ -123,53 +162,72 @@ class SandboxVisionTool(SandboxToolsBase):
{"param_name": "file_path", "node_type": "attribute", "path": "."}
],
example='''
<!-- Example: Request to see an image named 'diagram.png' inside the 'docs' folder -->
<!-- Example: Request to see a local image named 'diagram.png' inside the 'docs' folder -->
<function_calls>
<invoke name="see_image">
<parameter name="file_path">docs/diagram.png</parameter>
</invoke>
</function_calls>
<!-- Example: Request to see an image from a URL -->
<function_calls>
<invoke name="see_image">
<parameter name="file_path">https://example.com/image.jpg</parameter>
</invoke>
</function_calls>
'''
)
async def see_image(self, file_path: str) -> ToolResult:
"""Reads an image file, compresses it, converts it to base64, and adds it as a temporary message."""
"""Reads an image file from local file system or from a URL, compresses it, converts it to base64, and adds it as a temporary message."""
try:
# Ensure sandbox is initialized
await self._ensure_sandbox()
is_url = self.is_url(file_path)
if is_url:
try:
image_bytes, mime_type = self.download_image_from_url(file_path)
original_size = len(image_bytes)
cleaned_path = file_path
except Exception as e:
return self.fail_response(f"Failed to download image from URL: {str(e)}")
else:
# Ensure sandbox is initialized
await self._ensure_sandbox()
# Clean and construct full path
cleaned_path = self.clean_path(file_path)
full_path = f"{self.workspace_path}/{cleaned_path}"
# Clean and construct full path
cleaned_path = self.clean_path(file_path)
full_path = f"{self.workspace_path}/{cleaned_path}"
# Check if file exists and get info
try:
file_info = self.sandbox.fs.get_file_info(full_path)
if file_info.is_dir:
return self.fail_response(f"Path '{cleaned_path}' is a directory, not an image file.")
except Exception as e:
return self.fail_response(f"Image file not found at path: '{cleaned_path}'")
# Check if file exists and get info
try:
file_info = self.sandbox.fs.get_file_info(full_path)
if file_info.is_dir:
return self.fail_response(f"Path '{cleaned_path}' is a directory, not an image file.")
except Exception as e:
return self.fail_response(f"Image file not found at path: '{cleaned_path}'")
# Check file size
if file_info.size > MAX_IMAGE_SIZE:
return self.fail_response(f"Image file '{cleaned_path}' is too large ({file_info.size / (1024*1024):.2f}MB). Maximum size is {MAX_IMAGE_SIZE / (1024*1024)}MB.")
# Check file size
if file_info.size > MAX_IMAGE_SIZE:
return self.fail_response(f"Image file '{cleaned_path}' is too large ({file_info.size / (1024*1024):.2f}MB). Maximum size is {MAX_IMAGE_SIZE / (1024*1024)}MB.")
# Read image file content
try:
image_bytes = self.sandbox.fs.download_file(full_path)
except Exception as e:
return self.fail_response(f"Could not read image file: {cleaned_path}")
# Read image file content
try:
image_bytes = self.sandbox.fs.download_file(full_path)
except Exception as e:
return self.fail_response(f"Could not read image file: {cleaned_path}")
# Determine MIME type
mime_type, _ = mimetypes.guess_type(full_path)
if not mime_type or not mime_type.startswith('image/'):
# Basic fallback based on extension if mimetypes fails
ext = os.path.splitext(cleaned_path)[1].lower()
if ext == '.jpg' or ext == '.jpeg': mime_type = 'image/jpeg'
elif ext == '.png': mime_type = 'image/png'
elif ext == '.gif': mime_type = 'image/gif'
elif ext == '.webp': mime_type = 'image/webp'
else:
return self.fail_response(f"Unsupported or unknown image format for file: '{cleaned_path}'. Supported: JPG, PNG, GIF, WEBP.")
# Determine MIME type
mime_type, _ = mimetypes.guess_type(full_path)
if not mime_type or not mime_type.startswith('image/'):
# Basic fallback based on extension if mimetypes fails
ext = os.path.splitext(cleaned_path)[1].lower()
if ext == '.jpg' or ext == '.jpeg': mime_type = 'image/jpeg'
elif ext == '.png': mime_type = 'image/png'
elif ext == '.gif': mime_type = 'image/gif'
elif ext == '.webp': mime_type = 'image/webp'
else:
return self.fail_response(f"Unsupported or unknown image format for file: '{cleaned_path}'. Supported: JPG, PNG, GIF, WEBP.")
original_size = file_info.size
# Compress the image
compressed_bytes, compressed_mime_type = self.compress_image(image_bytes, mime_type, cleaned_path)
@ -186,7 +244,7 @@ class SandboxVisionTool(SandboxToolsBase):
"mime_type": compressed_mime_type,
"base64": base64_image,
"file_path": cleaned_path, # Include path for context
"original_size": file_info.size,
"original_size": original_size,
"compressed_size": len(compressed_bytes)
}
@ -200,7 +258,7 @@ class SandboxVisionTool(SandboxToolsBase):
)
# Inform the agent the image will be available next turn
return self.success_response(f"Successfully loaded and compressed the image '{cleaned_path}' (reduced from {file_info.size / 1024:.1f}KB to {len(compressed_bytes) / 1024:.1f}KB).")
return self.success_response(f"Successfully loaded and compressed the image '{cleaned_path}' (reduced from {original_size / 1024:.1f}KB to {len(compressed_bytes) / 1024:.1f}KB).")
except Exception as e:
return self.fail_response(f"An unexpected error occurred while trying to see the image: {str(e)}")

View File

@ -75,7 +75,7 @@ services:
max-file: "3"
healthcheck:
test: ["CMD", "uv", "run", "worker_health.py"]
timeout: 10s
timeout: 20s
interval: 30s
start_period: 40s

View File

@ -276,7 +276,7 @@ async def get_usage_logs(client, user_id: str, page: int = 0, items_per_page: in
# Use fixed cutoff date: June 26, 2025 midnight UTC
# Ignore all token counts before this date
cutoff_date = datetime(2025, 6, 28, 18, 0, 0, tzinfo=timezone.utc)
cutoff_date = datetime(2025, 6, 30, 9, 0, 0, tzinfo=timezone.utc)
start_of_month = max(start_of_month, cutoff_date)

View File

@ -13,7 +13,7 @@ async def main():
await retry(lambda: redis.initialize_async())
key = uuid.uuid4().hex
run_agent_background.check_health.send(key)
timeout = 5 # seconds
timeout = 20 # seconds
elapsed = 0
while elapsed < timeout:
if await redis.get(key) == "healthy":

View File

@ -31,8 +31,8 @@ import { useAgentStream } from '@/hooks/useAgentStream';
import { threadErrorCodeMessages } from '@/lib/constants/errorCodeMessages';
import { ThreadSkeleton } from '@/components/thread/content/ThreadSkeleton';
import { useAgent } from '@/hooks/react-query/agents/use-agents';
import { extractToolName } from '@/components/thread/tool-views/xml-parser';
// Extend the base Message type with the expected database fields
interface ApiMessageType extends BaseApiMessageType {
message_id?: string;
thread_id?: string;
@ -48,7 +48,6 @@ interface ApiMessageType extends BaseApiMessageType {
};
}
// Add a simple interface for streaming tool calls
interface StreamingToolCall {
id?: string;
name?: string;
@ -78,7 +77,6 @@ export default function ThreadPage({
const [currentToolIndex, setCurrentToolIndex] = useState<number>(0);
const [autoOpenedPanel, setAutoOpenedPanel] = useState(false);
// Playback control states
const [isPlaying, setIsPlaying] = useState(false);
const [currentMessageIndex, setCurrentMessageIndex] = useState(0);
const [streamingText, setStreamingText] = useState('');
@ -194,7 +192,7 @@ export default function ThreadPage({
(toolCall: StreamingToolCall | null) => {
if (!toolCall) return;
// Normalize the tool name by replacing underscores with hyphens
// Normalize the tool name like the project thread page does
const rawToolName = toolCall.name || toolCall.xml_tag_name || 'Unknown Tool';
const toolName = rawToolName.replace(/_/g, '-').toLowerCase();
@ -210,6 +208,7 @@ export default function ThreadPage({
// Format the arguments in a way that matches the expected XML format for each tool
// This ensures the specialized tool views render correctly
let formattedContent = toolArguments;
if (
toolName.includes('command') &&
!toolArguments.includes('<execute-command>')
@ -432,17 +431,10 @@ export default function ThreadPage({
return assistantMsg.content;
}
})();
// Try to extract tool name from content
const xmlMatch = assistantContent.match(
/<([a-zA-Z\-_]+)(?:\s+[^>]*)?>|<([a-zA-Z\-_]+)(?:\s+[^>]*)?\/>/,
);
if (xmlMatch) {
// Normalize tool name: replace underscores with hyphens and lowercase
const rawToolName = xmlMatch[1] || xmlMatch[2] || 'unknown';
toolName = rawToolName.replace(/_/g, '-').toLowerCase();
const extractedToolName = extractToolName(assistantContent);
if (extractedToolName) {
toolName = extractedToolName;
} else {
// Fallback to checking for tool_calls JSON structure
const assistantContentParsed = safeJsonParse<{
tool_calls?: Array<{ function?: { name?: string }; name?: string }>;
}>(assistantMsg.content, {});
@ -452,7 +444,6 @@ export default function ThreadPage({
) {
const firstToolCall = assistantContentParsed.tool_calls[0];
const rawName = firstToolCall.function?.name || firstToolCall.name || 'unknown';
// Normalize tool name here too
toolName = rawName.replace(/_/g, '-').toLowerCase();
}
}
@ -460,7 +451,6 @@ export default function ThreadPage({
let isSuccess = true;
try {
// Parse tool result content
const toolResultContent = (() => {
try {
const parsed = safeJsonParse<{ content?: string }>(resultMessage.content, {});
@ -469,15 +459,11 @@ export default function ThreadPage({
return resultMessage.content;
}
})();
// Check for ToolResult pattern first
if (toolResultContent && typeof toolResultContent === 'string') {
// Look for ToolResult(success=True/False) pattern
const toolResultMatch = toolResultContent.match(/ToolResult\s*\(\s*success\s*=\s*(True|False|true|false)/i);
if (toolResultMatch) {
isSuccess = toolResultMatch[1].toLowerCase() === 'true';
} else {
// Fallback: only check for error keywords if no ToolResult pattern found
const toolContent = toolResultContent.toLowerCase();
isSuccess = !(toolContent.includes('failed') ||
toolContent.includes('error') ||
@ -489,19 +475,17 @@ export default function ThreadPage({
historicalToolPairs.push({
assistantCall: {
name: toolName,
content: assistantMsg.content, // Store original content
content: assistantMsg.content,
timestamp: assistantMsg.created_at,
},
toolResult: {
content: resultMessage.content, // Store original content
content: resultMessage.content,
isSuccess: isSuccess,
timestamp: resultMessage.created_at,
},
});
}
});
// Sort the tool calls chronologically by timestamp
historicalToolPairs.sort((a, b) => {
const timeA = new Date(a.assistantCall.timestamp || '').getTime();
const timeB = new Date(b.assistantCall.timestamp || '').getTime();
@ -509,8 +493,6 @@ export default function ThreadPage({
});
setToolCalls(historicalToolPairs);
// When loading is complete, prepare for playback
initialLoadCompleted.current = true;
}
} catch (err) {
@ -525,9 +507,7 @@ export default function ThreadPage({
if (isMounted) setIsLoading(false);
}
}
loadData();
return () => {
isMounted = false;
};
@ -546,7 +526,6 @@ export default function ThreadPage({
messagesEndRef.current?.scrollIntoView({ behavior });
};
// Handle tool clicks
const handleToolClick = useCallback(
(clickedAssistantMessageId: string | null, clickedToolName: string) => {
if (!clickedAssistantMessageId) {
@ -557,7 +536,6 @@ export default function ThreadPage({
return;
}
// Reset user closed state when explicitly clicking a tool
userClosedPanelRef.current = false;
console.log(
@ -567,21 +545,16 @@ export default function ThreadPage({
clickedToolName,
);
// Find the index of the tool call associated with the clicked assistant message
const toolIndex = toolCalls.findIndex((tc) => {
// Check if the assistant message ID matches the one stored in the tool result's metadata
if (!tc.toolResult?.content || tc.toolResult.content === 'STREAMING')
return false; // Skip streaming or incomplete calls
return false;
// Find the original assistant message based on the ID
const assistantMessage = messages.find(
(m) =>
m.message_id === clickedAssistantMessageId &&
m.type === 'assistant',
);
if (!assistantMessage) return false;
// Find the corresponding tool message using metadata
const toolMessage = messages.find((m) => {
if (m.type !== 'tool' || !m.metadata) return false;
try {
@ -593,9 +566,6 @@ export default function ThreadPage({
return false;
}
});
// Check if the current toolCall 'tc' corresponds to this assistant/tool message pair
// Compare the original content directly without parsing
return (
tc.assistantCall?.content === assistantMessage.content &&
tc.toolResult?.content === toolMessage?.content
@ -608,7 +578,7 @@ export default function ThreadPage({
);
setExternalNavIndex(toolIndex);
setCurrentToolIndex(toolIndex);
setIsSidePanelOpen(true); // Explicitly open the panel
setIsSidePanelOpen(true);
setTimeout(() => setExternalNavIndex(undefined), 100);
} else {
@ -630,7 +600,6 @@ export default function ThreadPage({
setFileViewerOpen(true);
}, []);
// Initialize PlaybackControls
const playbackController: PlaybackController = PlaybackControls({
messages,
isSidePanelOpen,
@ -641,7 +610,6 @@ export default function ThreadPage({
projectName: projectName || 'Shared Conversation',
});
// Extract the playback state and functions
const {
playbackState,
renderHeader,
@ -652,21 +620,17 @@ export default function ThreadPage({
skipToEnd,
} = playbackController;
// Connect playbackState to component state
useEffect(() => {
// Keep the isPlaying state in sync with playbackState
setIsPlaying(playbackState.isPlaying);
setCurrentMessageIndex(playbackState.currentMessageIndex);
}, [playbackState.isPlaying, playbackState.currentMessageIndex]);
// Auto-scroll when new messages appear during playback
useEffect(() => {
if (playbackState.visibleMessages.length > 0 && !userHasScrolled) {
scrollToBottom('smooth');
}
}, [playbackState.visibleMessages, userHasScrolled]);
// Scroll button visibility
useEffect(() => {
if (!latestMessageRef.current || playbackState.visibleMessages.length === 0)
return;
@ -683,7 +647,6 @@ export default function ThreadPage({
`[PAGE] 🔄 Page AgentStatus: ${agentStatus}, Hook Status: ${streamHookStatus}, Target RunID: ${agentRunId || 'none'}, Hook RunID: ${currentHookRunId || 'none'}`,
);
// If the stream hook reports completion/stopping but our UI hasn't updated
if (
(streamHookStatus === 'completed' ||
streamHookStatus === 'stopped' ||
@ -700,7 +663,6 @@ export default function ThreadPage({
}
}, [agentStatus, streamHookStatus, agentRunId, currentHookRunId]);
// Auto-scroll function for use throughout the component
const autoScrollToBottom = useCallback(
(behavior: ScrollBehavior = 'smooth') => {
if (!userHasScrolled && messagesEndRef.current) {
@ -710,31 +672,21 @@ export default function ThreadPage({
[userHasScrolled],
);
// Very direct approach to update the tool index during message playback
useEffect(() => {
if (!isPlaying || currentMessageIndex <= 0 || !messages.length) return;
// Check if current message is a tool message
const currentMsg = messages[currentMessageIndex - 1]; // Look at previous message that just played
const currentMsg = messages[currentMessageIndex - 1];
if (currentMsg?.type === 'tool' && currentMsg.metadata) {
try {
const metadata = safeJsonParse<ParsedMetadata>(currentMsg.metadata, {});
const assistantId = metadata.assistant_message_id;
if (assistantId) {
// Find the tool call that matches this assistant message
const toolIndex = toolCalls.findIndex((tc) => {
// Find the assistant message
const assistantMessage = messages.find(
(m) => m.message_id === assistantId && m.type === 'assistant'
);
if (!assistantMessage) return false;
// Check if this tool call matches
return tc.assistantCall?.content === assistantMessage.content;
});
if (toolIndex !== -1) {
console.log(
`Direct mapping: Setting tool index to ${toolIndex} for message ${assistantId}`,
@ -748,28 +700,19 @@ export default function ThreadPage({
}
}, [currentMessageIndex, isPlaying, messages, toolCalls]);
// Force an explicit update to the tool panel based on the current message index
useEffect(() => {
// Skip if not playing or no messages
if (!isPlaying || messages.length === 0 || currentMessageIndex <= 0) return;
// Get all messages up to the current index
const currentMessages = messages.slice(0, currentMessageIndex);
// Find the most recent tool message to determine which panel to show
for (let i = currentMessages.length - 1; i >= 0; i--) {
const msg = currentMessages[i];
if (msg.type === 'tool' && msg.metadata) {
try {
const metadata = safeJsonParse<ParsedMetadata>(msg.metadata, {});
const assistantId = metadata.assistant_message_id;
if (assistantId) {
console.log(
`Looking for tool panel for assistant message ${assistantId}`,
);
// Scan for matching tool call
for (let j = 0; j < toolCalls.length; j++) {
const content = toolCalls[j].assistantCall?.content || '';
if (content.includes(assistantId)) {
@ -788,14 +731,12 @@ export default function ThreadPage({
}
}, [currentMessageIndex, isPlaying, messages, toolCalls]);
// Loading skeleton UI
if (isLoading && !initialLoadCompleted.current) {
return (
<ThreadSkeleton isSidePanelOpen={isSidePanelOpen} showHeader={true} />
);
}
// Error state UI
if (error) {
return (
<div className="flex h-screen">

View File

@ -364,6 +364,12 @@ export function constructImageUrl(filePath: string, project?: { sandbox?: { sand
}
const cleanPath = filePath.replace(/^['"](.*)['"]$/, '$1');
// Check if it's a URL first, before trying to construct sandbox paths
if (cleanPath.startsWith('http')) {
return cleanPath;
}
const sandboxId = typeof project?.sandbox === 'string'
? project.sandbox
: project?.sandbox?.id;
@ -390,10 +396,6 @@ export function constructImageUrl(filePath: string, project?: { sandbox?: { sand
return fullUrl;
}
if (cleanPath.startsWith('http')) {
return cleanPath;
}
console.warn('No sandbox URL or ID available, using path as-is:', cleanPath);
return cleanPath;
}