diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml index de4e467..f527b7f 100644 --- a/.github/workflows/pull-request.yaml +++ b/.github/workflows/pull-request.yaml @@ -70,7 +70,7 @@ jobs: - name: Setup .env run: | cat < .env - MISTRAL_API_KEY=${{ secrets.MISTRAL_API_KEY }} + LLM_PROVIDER_KEY=${{ secrets.MISTRAL_API_KEY }} MAPTILER_API_KEY=${{ secrets.MAPTILER_KEY }} LOG_LEVEL=DEBUG VERSION="x.x.x-testing" @@ -125,4 +125,4 @@ jobs: - name: Build image run: | cd frontend - scripts/build-docker-image --maptiler-key ${{ secrets.MAPTILER_KEY }} + scripts/build-docker-image diff --git a/backend/.env.example b/backend/.env.example index f0b55d8..6d4eedd 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,11 +1,29 @@ -MISTRAL_API_KEY=your_mistral_api_key_here +# Copy to .env and fill in the blanks. -# Get a free key from https://www.maptiler.com/ (used both by the frontend -# map and this backend's get_satellite_image tool) -MAPTILER_API_KEY=your_maptiler_api_key +# --- LLM ---------------------------------------------------------------- +# API key for the provider named in LLM_PROVIDER (required). +LLM_PROVIDER_KEY= +# LangChain provider id. Change this and the two models below to use +# something other than Mistral, and install that provider's integration +# package (e.g. `uv add langchain-openai`). +LLM_PROVIDER=mistralai +LLM_LARGE_MODEL=mistral-large-latest +LLM_SMALL_MODEL=mistral-small-latest -# LANGFUSE_HOST= -LANGFUSE_ENABLED=false +# --- Map / imagery ------------------------------------------------------ +# Free key from https://www.maptiler.com/ (same key the frontend uses). +MAPTILER_API_KEY= + +# --- API ---------------------------------------------------------------- +# Origins allowed to call this API. The frontend dev server runs on :9000. +ALLOWED_ORIGINS=["http://localhost:9000"] LOG_LEVEL=INFO -STABLE=true +VERSION=x.x.x-dev + +# --- Tracing (optional) ------------------------------------------------- +# Langfuse powers the /ratings endpoint; leave disabled for local dev. +LANGFUSE_ENABLED=false +LANGFUSE_PUBLIC_KEY= +LANGFUSE_SECRET_KEY= +LANGFUSE_HOST= diff --git a/backend/README.md b/backend/README.md index 860d29f..4226b91 100644 --- a/backend/README.md +++ b/backend/README.md @@ -25,7 +25,19 @@ You will need the following installed: ## LLM dependencies -The app uses the Mistral AI API for the LLM, which requires an API key. Set `MISTRAL_API_KEY` in your `.env` file. +The LLM provider is configurable. The agent uses two models — a `large` one (used by the +agent graph) and a `small` one (available for cheaper, narrower tasks) — both created via +LangChain's `init_chat_model`, so any provider it supports can be used. + +Set the following in your `.env` file: + +- `LLM_PROVIDER_KEY` (required) - the API key for the provider +- `LLM_PROVIDER` - the LangChain provider id, defaults to `mistralai` +- `LLM_LARGE_MODEL` - defaults to `mistral-large-latest` +- `LLM_SMALL_MODEL` - defaults to `mistral-small-latest` + +If you switch provider, install its LangChain integration package (e.g. +`uv add langchain-openai`) in place of `langchain-mistralai`. ## Map / imagery dependencies diff --git a/backend/pyproject.toml b/backend/pyproject.toml index a4ac4e8..1acfc51 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,11 +1,10 @@ [project] name = "agentkai" -version = "0.12.0" +version = "0.13.0" description = "A barebones demo of an agentic chat application" readme = "README.md" requires-python = ">=3.12" dependencies = [ - "dotenv>=0.9.9", "fastapi>=0.115.9", "httpx[http2]>=0.28.1", "langchain>=0.3.26", diff --git a/backend/src/agent_kai/agent/graph.py b/backend/src/agent_kai/agent/graph.py index e2264ad..2490680 100644 --- a/backend/src/agent_kai/agent/graph.py +++ b/backend/src/agent_kai/agent/graph.py @@ -3,7 +3,7 @@ from langchain.agents import create_agent from langgraph.checkpoint.memory import InMemorySaver -from agent_kai.agent.llm import mistral_large as llm +from agent_kai.agent.llm import large as llm from agent_kai.agent.state import AgentState from agent_kai.tools.aoi import get_area_of_interest from agent_kai.tools.satellite_image import get_satellite_image @@ -20,17 +20,22 @@ imagery of the current area of interest. This requires an AOI to already be set — call `get_area_of_interest` first if the user hasn't named a place yet in this conversation. Note: this demo does not call a real - satellite imagery provider — it returns a random fun image (a cat, a - flower, or NASA's Astronomy Picture of the Day) instead. Present it to + satellite imagery provider — it returns a random fun image (a cat or a + flower) instead. Present it to the user as the fun surprise image it is, not as real imagery of the - place. + place. +- IT IS IMPERITIVE YOU NEVER RETURN A PICTURE IN YOUR RESPONSE. Tools will set an image + in state if necessary and those are rendered clientside. General guidelines: - Strictly use the tools provided to answer queries. Do not rely on internal knowledge about specific places. - If you are unable to fulfil a request with the available tools, say so plainly rather than guessing. +""" + +""" ============================================================================ HOW TO ADD A NEW TOOL ============================================================================ diff --git a/backend/src/agent_kai/agent/llm.py b/backend/src/agent_kai/agent/llm.py index 2bbcb6a..e344b97 100644 --- a/backend/src/agent_kai/agent/llm.py +++ b/backend/src/agent_kai/agent/llm.py @@ -1,36 +1,19 @@ -from langchain_mistralai import ChatMistralAI -from pydantic import SecretStr +from langchain.chat_models import init_chat_model +from langchain_core.language_models import BaseChatModel from agent_kai.settings import get_settings settings = get_settings() -mistral_large = ChatMistralAI( - model_name="mistral-large-latest", - api_key=SecretStr(settings.mistral_api_key), - temperature=0.0, -) -mistral_medium = ChatMistralAI( - model_name="mistral-medium-latest", - api_key=SecretStr(settings.mistral_api_key), - temperature=0.0, -) +def _build_model(model_name: str) -> BaseChatModel: + return init_chat_model( + model_name, + model_provider=settings.llm_provider, + api_key=settings.llm_provider_key, + temperature=0.0, + ) -mistral_small = ChatMistralAI( - model_name="mistral-small-latest", - api_key=SecretStr(settings.mistral_api_key), - temperature=0.0, -) -magistral_medium = ChatMistralAI( - model_name="magistral-medium-latest", - api_key=SecretStr(settings.mistral_api_key), - temperature=0.0, -) - -magistral_small = ChatMistralAI( - model_name="magistral-small-latest", - api_key=SecretStr(settings.mistral_api_key), - temperature=0.0, -) +large: BaseChatModel = _build_model(settings.llm_large_model) +small: BaseChatModel = _build_model(settings.llm_small_model) diff --git a/backend/src/agent_kai/agent/state.py b/backend/src/agent_kai/agent/state.py index 93ec2da..e734aaa 100644 --- a/backend/src/agent_kai/agent/state.py +++ b/backend/src/agent_kai/agent/state.py @@ -16,6 +16,6 @@ class AgentState(LangchainAgentState): Annotated[ dict[str, Any] | None, "An image artifact to display to the user, shaped as " - "{'type': 'image/png', 'data': }.", + "{'type': , 'url': }.", ] ] diff --git a/backend/src/agent_kai/api/app.py b/backend/src/agent_kai/api/app.py index 786123c..c883920 100644 --- a/backend/src/agent_kai/api/app.py +++ b/backend/src/agent_kai/api/app.py @@ -1,4 +1,3 @@ -import datetime import json import logging import sys @@ -6,7 +5,6 @@ from http import HTTPStatus from typing import Any, AsyncGenerator, Dict, cast -from dotenv import load_dotenv from fastapi import FastAPI, Request, Response from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse, StreamingResponse @@ -25,7 +23,6 @@ from agent_kai.api.schemas.version import VersionResponse from agent_kai.settings import get_settings -load_dotenv() settings = get_settings() # Whitelist state fields that can be set by the user. @@ -55,7 +52,10 @@ async def lifespan(app: FastAPI): app.add_middleware( CORSMiddleware, - allow_origins=["*"], # Allows all origins + # Set ALLOWED_ORIGINS in your .env to wherever the frontend is served from. + # Browsers reject a wildcard origin on credentialed requests, so this has + # to be an explicit list. + allow_origins=settings.allowed_origins, allow_credentials=True, allow_methods=["*"], # Allows all HTTP methods (GET, POST, PUT, DELETE, etc.) allow_headers=["*"], # Allows all headers @@ -85,19 +85,6 @@ async def readiness() -> JSONResponse: ) -def _stringify_dates(obj: Any) -> Any: - if isinstance(obj, list): - return [_stringify_dates(item) for item in obj] - elif isinstance(obj, dict): - return {key: _stringify_dates(value) for key, value in obj.items()} - elif isinstance(obj, datetime.date): - return obj.strftime("%Y%m%d") - elif isinstance(obj, datetime.datetime): - return obj.isoformat() - else: - return obj - - async def stream_chat( ui_state_update: AgentState, thread_id: UUID4, @@ -188,6 +175,12 @@ async def chat(request: ChatRequestBody, http_request: Request) -> StreamingResp @app.post("/ratings") async def create_rating(request: CreateRatingBody) -> Response: + # Ratings are stored as Langfuse scores against the trace of the response + # being rated, so there is nowhere to put them when tracing is off. + if not settings.langfuse_enabled: + logger.info("Rating discarded: langfuse is not enabled.") + return Response(status_code=HTTPStatus.SERVICE_UNAVAILABLE) + try: langfuse_client.create_score( name="user-feedback", diff --git a/backend/src/agent_kai/models/__init__.py b/backend/src/agent_kai/models/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/src/agent_kai/settings/__init__.py b/backend/src/agent_kai/settings/__init__.py index 6a7200f..4569777 100644 --- a/backend/src/agent_kai/settings/__init__.py +++ b/backend/src/agent_kai/settings/__init__.py @@ -14,12 +14,16 @@ class Settings(BaseSettings): - mistral_api_key: str + llm_provider: str = "mistralai" + llm_provider_key: str + llm_large_model: str = "mistral-large-latest" + llm_small_model: str = "mistral-small-latest" maptiler_api_key: str langfuse_public_key: str | None = None langfuse_secret_key: str | None = None langfuse_host: HttpUrl | None = None langfuse_enabled: bool = False + allowed_origins: list[str] = ["http://localhost:9000"] log_level: LOG_LEVEL = "DEBUG" version: str = "N/A" stable: bool = True # Represents whether we know of any issues affecting our service that are current diff --git a/backend/src/agent_kai/tools/satellite_image.py b/backend/src/agent_kai/tools/satellite_image.py index 3e98088..8f6b068 100644 --- a/backend/src/agent_kai/tools/satellite_image.py +++ b/backend/src/agent_kai/tools/satellite_image.py @@ -20,17 +20,13 @@ logger = logging.getLogger(__name__) CAT_API_URL = "https://api.thecatapi.com/v1/images/search" -NASA_APOD_API_URL = "https://api.nasa.gov/planetary/apod" -# NASA's shared, no-signup-required key for api.nasa.gov (rate-limited but -# keyless): https://api.nasa.gov/#api-keys -NASA_APOD_DEMO_KEY = "DEMO_KEY" WIKIMEDIA_COMMONS_API_URL = "https://commons.wikimedia.org/w/api.php" FLOWER_CATEGORY = "Category:Flowers" # Wikimedia's API etiquette policy rejects requests with a generic/missing # User-Agent: https://meta.wikimedia.org/wiki/User-Agent_policy WIKIMEDIA_USER_AGENT = "AgentKai/1.0 (demo app)" -IMAGE_SOURCES = ("cat", "flower", "nasa") +IMAGE_SOURCES = ("cat", "flower") async def fetch_cat_image_url(client: httpx.AsyncClient) -> str: @@ -64,22 +60,6 @@ async def fetch_flower_image_url(client: httpx.AsyncClient) -> str: return random.choice(pages)["imageinfo"][0]["url"] -async def fetch_nasa_apod_image_url(client: httpx.AsyncClient, api_key: str) -> str: - """Fetch a random NASA Astronomy Picture of the Day image URL. - - `count=1` asks NASA's APOD API for one random entry from its archive. - Some entries are videos rather than images, so we retry a few times - until we land on an image. - """ - for _ in range(5): - response = await client.get(NASA_APOD_API_URL, params={"api_key": api_key, "count": 1}) - response.raise_for_status() - entry = response.json()[0] - if entry.get("media_type") == "image": - return entry.get("hdurl") or entry["url"] - raise RuntimeError("Could not find a NASA APOD entry with an image after 5 attempts") - - async def fetch_random_fun_image_url() -> tuple[str, str]: """Pick a random source and fetch an image URL from it. @@ -87,13 +67,13 @@ async def fetch_random_fun_image_url() -> tuple[str, str]: """ source = random.choice(IMAGE_SOURCES) # http2=True: Wikimedia's edge rejects HTTP/1.1-only clients as bots. - async with httpx.AsyncClient(follow_redirects=True, timeout=15, http2=True) as client: + async with httpx.AsyncClient( + follow_redirects=True, timeout=15, http2=True + ) as client: if source == "cat": image_url = await fetch_cat_image_url(client) elif source == "flower": image_url = await fetch_flower_image_url(client) - else: - image_url = await fetch_nasa_apod_image_url(client, NASA_APOD_DEMO_KEY) return source, image_url @@ -105,9 +85,7 @@ async def get_satellite_image( """Fetch an image to show the user in place of real satellite imagery. This demo does not call a real satellite imagery provider. It instead - returns a random fun image — a cat, a flower, or NASA's Astronomy - Picture of the Day — so the tool-call flow can be exercised without a - satellite imagery API key. + returns a random fun image — a cat, or a flower Requires an AOI to already be set in this conversation — if the user hasn't named a place yet, call `get_area_of_interest` first. diff --git a/backend/tests/api/test_ratings_endpoint.py b/backend/tests/api/test_ratings_endpoint.py index fc86fcd..0114f46 100644 --- a/backend/tests/api/test_ratings_endpoint.py +++ b/backend/tests/api/test_ratings_endpoint.py @@ -1,5 +1,13 @@ from unittest.mock import patch +import pytest + + +@pytest.fixture +def langfuse_enabled(): + with patch("agent_kai.api.app.settings.langfuse_enabled", True): + yield + class TestRatingsEndpoint: @patch("agent_kai.api.app.langfuse_client", create=True) @@ -7,6 +15,7 @@ def test_ratings_endpoint_invokes_langfuse_client_with_provided_values( self, patched_langfuse_client, test_client, + langfuse_enabled, ): resp = test_client.post( "/ratings", @@ -26,6 +35,7 @@ def test_ratings_endpoint_returns_error_if_langfuse_does( self, patched_langfuse_client, test_client, + langfuse_enabled, ): patched_langfuse_client.create_score.side_effect = Exception("An exception") resp = test_client.post( @@ -34,6 +44,15 @@ def test_ratings_endpoint_returns_error_if_langfuse_does( ) assert resp.status_code == 500 + def test_ratings_endpoint_is_unavailable_when_langfuse_is_disabled( + self, test_client + ): + resp = test_client.post( + "/ratings", + json={"trace_id": "an-id", "rating": 1, "comment": "this was awesome!"}, + ) + assert resp.status_code == 503 + def test_ratings_endpoint_correctly_validates_inputs(self, test_client): resp = test_client.post("/ratings", json={"blah": "blah"}) assert resp.status_code == 422 diff --git a/backend/uv.lock b/backend/uv.lock index 74814a0..c1fe0c1 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -15,10 +15,9 @@ resolution-markers = [ [[package]] name = "agentkai" -version = "0.12.0" +version = "0.13.0" source = { editable = "." } dependencies = [ - { name = "dotenv" }, { name = "fastapi" }, { name = "httpx", extra = ["http2"] }, { name = "langchain" }, @@ -43,7 +42,6 @@ dev = [ [package.metadata] requires-dist = [ - { name = "dotenv", specifier = ">=0.9.9" }, { name = "fastapi", specifier = ">=0.115.9" }, { name = "httpx", extras = ["http2"], specifier = ">=0.28.1" }, { name = "langchain", specifier = ">=0.3.26" }, @@ -318,17 +316,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] -[[package]] -name = "dotenv" -version = "0.9.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "python-dotenv" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892, upload-time = "2025-02-19T22:15:01.647Z" }, -] - [[package]] name = "fastapi" version = "0.124.0" diff --git a/frontend/app/stores/chat-store.tsx b/frontend/app/stores/chat-store.tsx index 58a1fd2..9be0730 100644 --- a/frontend/app/stores/chat-store.tsx +++ b/frontend/app/stores/chat-store.tsx @@ -7,10 +7,15 @@ import { cerror, clog, dev } from '$utils/dev'; import devMesg from './msg.dev.json'; import useArtifactStore from './artifact-store'; -import { processArtifactMessage } from '../artifacts/config'; +import { ArtifactMessages, processArtifactMessage } from '../artifacts/config'; const API_BASE_URL = import.meta.env.VITE_API_URL || ''; +// Message types that render as an artifact, and so should pop the artifact +// viewer open when one arrives. Extend this when adding a new artifact type +// to `ArtifactMessages`. +const ARTIFACT_MESSAGE_TYPES: ArtifactMessages['type'][] = ['aoi', 'image']; + interface ChatState { messages: ChatMessage[]; isLoading: boolean; @@ -202,8 +207,8 @@ const useChatStore = create((set, get) => ({ const newMessages = get().messages.slice(currMessageCount); const latestArtifact = newMessages .reverse() - .find((msg) => - ['aoi', 'parameter', 'plot', 'polytope'].includes(msg.type) + .find((msg): msg is ArtifactMessages => + ARTIFACT_MESSAGE_TYPES.includes(msg.type as ArtifactMessages['type']) ); if (latestArtifact) { // If there's an artifact message, open it in the artifact viewer diff --git a/frontend/app/stores/types.d.ts b/frontend/app/stores/types.d.ts index 206dd90..792fd66 100644 --- a/frontend/app/stores/types.d.ts +++ b/frontend/app/stores/types.d.ts @@ -1,4 +1,8 @@ -import { ArtifactImage, ArtifactMessages } from '$artifacts/config'; +import { + ArtifactAOI, + ArtifactImage, + ArtifactMessages +} from '$artifacts/config'; export type ArtifactChatMessage = { traceId: string; @@ -63,14 +67,14 @@ export interface AgentStateMessage { messages: ReadonlyArray; error?: unknown | null; - /** AgentState-specific fields */ - parameter_infos: ArtifactParameter[]; - polytope_request?: ArtifactPolytope | null; - dt_plot?: ArtifactPlot | null; + /** + * Artifact-producing fields, mirroring the extra fields on `AgentState` in + * `backend/src/agent_kai/agent/state.py`. Add a field here when you add one + * there, and handle it in `processArtifactMessage`. + */ aoi?: ArtifactAOI | null; image?: ArtifactImage | null; - remaining_steps: number; - onyx_chat_session_id?: string | null; - onyx_chat_auth_cookie?: string | null; + /** Set by LangGraph, not by our tools. */ + remaining_steps?: number; } diff --git a/frontend/package.json b/frontend/package.json index 1425922..37230f8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "agentkai", "description": "AgentKai. Your personal AI", - "version": "0.0.1", + "version": "0.0.2", "repository": { "type": "git", "url": "https://github.com/developmentseed/ororatech-wildfire-insights-agent.git" diff --git a/frontend/scripts/build-docker-image b/frontend/scripts/build-docker-image index 5faa839..b66bb07 100755 --- a/frontend/scripts/build-docker-image +++ b/frontend/scripts/build-docker-image @@ -2,69 +2,101 @@ set -e +IMAGE="agentkai-frontend" REGISTRY= BASE_URL= API_URL= -APP_TITLE= +APP_TITLE="AgentKai" +APP_DESCRIPTION="A barebones demo of an agentic chat application" TAG="latest" PUSH=false MAPTILER_KEY="" -LOCAL_IMAGE= + +usage() { + echo "Usage: $0 [--maptiler-key ] [--tag ] [--push] [--base-url ] [--api-url ] [--app-title ] [--registry <registry>]" +} + +# Every long option below takes a value, so check one is actually there +# before shifting past it - an unset CI secret expands to nothing and would +# otherwise blow up on `shift 2`. +require_value() { + if [ $# -lt 2 ]; then + echo "❌ Error: $1 requires a value." + usage + exit 1 + fi +} while [ $# -gt 0 ]; do case "$1" in - --tag) - TAG="$2" - shift 2 - ;; --push) PUSH=true shift ;; + --tag) + require_value "$@" + TAG="$2" + shift 2 + ;; --base-url) + require_value "$@" BASE_URL="$2" shift 2 ;; --api-url) + require_value "$@" API_URL="$2" shift 2 ;; --app-title) + require_value "$@" APP_TITLE="$2" shift 2 ;; --maptiler-key) + require_value "$@" MAPTILER_KEY="$2" shift 2 ;; --registry) + require_value "$@" REGISTRY="$2" shift 2 ;; *) echo "Unknown option: $1" - echo "Usage: $0 --maptiler-key <key> [--tag <tag>] [--push] [--base-url <url>] [--api-url <url>] [--app-title <title>] [--registry <registry>]" + usage exit 1 ;; esac done +# The key is baked into the bundle at build time, so the map won't render +# without it. It isn't required to check that the image builds, though. if [ -z "$MAPTILER_KEY" ]; then - echo "❌ Error: --maptiler-key is required." - echo "Usage: $0 --maptiler-key <key> [--tag <tag>] [--push] [--base-url <url>] [--api-url <url>] [--app-title <title>]" - exit 1 + echo "⚠️ Warning: no --maptiler-key given; the map will not render in this image." fi -TAGGED_IMAGE="$REGISTRY:$TAG" -LATEST_IMAGE="$REGISTRY:latest" +LOCAL_IMAGE="$IMAGE:$TAG" DOCKER_DEFAULT_PLATFORM="linux/amd64" docker build \ --build-arg VITE_BASE_URL="$BASE_URL" \ --build-arg VITE_API_URL="$API_URL" \ --build-arg VITE_APP_TITLE="$APP_TITLE" \ - --build-arg VITE_APP_DESCRIPTION="Destination Earth Digital Twin Assistant" \ + --build-arg VITE_APP_DESCRIPTION="$APP_DESCRIPTION" \ --build-arg VITE_MAPTILER_KEY="$MAPTILER_KEY" \ - -t "$LOCAL_IMAGE" . + -t "$LOCAL_IMAGE" . + +if [ -z "$REGISTRY" ]; then + if [ "$PUSH" = true ]; then + echo "❌ Error: --push requires --registry." + exit 1 + fi + exit 0 +fi + +TAGGED_IMAGE="$REGISTRY/$IMAGE:$TAG" +LATEST_IMAGE="$REGISTRY/$IMAGE:latest" docker tag "$LOCAL_IMAGE" "$TAGGED_IMAGE"