Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 57 additions & 2 deletions hub/agents/chat/python/gaia_agent_chat/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@
ScreenshotToolsMixin,
ShellToolsMixin,
)
from gaia.llm.inference_location import (
InferenceLocation,
resolve_inference_location,
)
from gaia.llm.lemonade_client import (
DEFAULT_MODEL_NAME,
is_tool_calling_model,
Expand Down Expand Up @@ -879,6 +883,45 @@ def _get_mixin_prompts(self) -> list[str]:
origins = getattr(self, "_mixin_prompt_origins", {})
return [p for p in prompts if origins.get(p) != "get_sd_system_prompt"]

def _inference_location(self) -> InferenceLocation:
"""Where this session's chat turns are actually answered (#3674).

Reads the live client when there is one. During ``__init__`` the prompt
can be composed before ``AgentSDK`` exists (MCP registration rebuilds
it), and the same backend choice is already on the agent itself by
then; every later rebuild — including the one every model switch runs —
sees the client.
"""
chat = getattr(self, "chat", None)
if chat is not None:
# The live client classifies better than the id prefix can: it
# holds the catalog metadata, so a cloud provider discovered at
# runtime is recognised too.
lookup = getattr(
getattr(chat, "llm_client", None), "cloud_model_provider", None
)
return resolve_inference_location(
chat.effective_model,
use_claude=bool(chat.config.use_claude),
use_openai=bool(getattr(chat.config, "use_chatgpt", False)),
cloud_provider_lookup=lookup if callable(lookup) else None,
)
# Pre-client: read the config the client is about to be built from —
# it carries the same three answers, including the Claude model id,
# which ``model_id`` does not.
config = getattr(self, "config", None)
use_claude = bool(getattr(config, "use_claude", False))
model = (
getattr(config, "claude_model", None)
if use_claude
else getattr(config, "model_id", None)
)
return resolve_inference_location(
model or DEFAULT_MODEL_NAME,
use_claude=use_claude,
use_openai=bool(getattr(config, "use_chatgpt", False)),
)

def _get_system_prompt(self) -> str:
"""Generate the system prompt for the Chat Agent."""
profile = getattr(self.config, "prompt_profile", "full")
Expand Down Expand Up @@ -1001,8 +1044,20 @@ def _get_system_prompt(self) -> str:
- CPU: `lscpu`, GPU: `lspci | grep VGA`, Memory: `free -h`
"""

base_prompt = f"""You are GAIA — a personal AI running locally on the user's machine. Sharp, witty, genuinely fun. Think: the smartest person at the party, who's also nice.
{platform_block}
location = self._inference_location()
whereabouts = "running locally on the user's machine"
if location.remote:
whereabouts = (
"on the user's machine, thinking on " f"{location.display} this session"
)
inference_block = f"""
**WHERE THIS SESSION IS PROCESSED:** {location.describe()}
- Answer any question about your provider, model, or whether you are local or
cloud from THIS line. Never read config files or run commands to find out.
"""

base_prompt = f"""You are GAIA — a personal AI {whereabouts}. Sharp, witty, genuinely fun. Think: the smartest person at the party, who's also nice.
{platform_block}{inference_block}

**WHO YOU ARE:**
- You're GAIA. Not "an AI assistant" or "a helpful tool" — just GAIA.
Expand Down
22 changes: 7 additions & 15 deletions hub/agents/gaia/python/gaia_agent/stdio.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
from gaia_agent.memory_dump import MEMORY_DUMP_QUERY, build_memory_dump

from gaia.llm import create_client
from gaia.llm.inference_location import resolve_inference_location
from gaia.llm.lemonade_client import (
DEFAULT_LEMONADE_URL,
LemonadeClient,
Expand Down Expand Up @@ -696,23 +697,14 @@ def run_model_command(agent: Any, query: str, out) -> None:

logger.info("switched model to %s (%s)", arg, display)
_write(_model_state_event(agent), out)
where = (
"Claude API — this conversation is sent to Anthropic"
if agent._use_claude
else "the local Lemonade backend"
location = resolve_inference_location(
agent.chat.effective_model, use_claude=bool(agent._use_claude)
)
cloud_provider = cloud_model_provider(agent.chat.effective_model)
if cloud_provider and not agent._use_claude:
provider_name = {
"fireworks": "Fireworks AI",
"amd": "AMD LLM Gateway",
}.get(cloud_provider, cloud_provider)
where = (
f"{provider_name} via Lemonade — this conversation is sent to "
f"{provider_name}; embeddings stay on Lemonade"
)
_write(
{"type": "final", "answer": f"Switched to **{display}**, running on {where}."},
{
"type": "final",
"answer": f"Switched to **{display}**. {location.describe()}",
},
out,
)

Expand Down
5 changes: 4 additions & 1 deletion hub/agents/gaia/python/tests/test_stdio.py
Original file line number Diff line number Diff line change
Expand Up @@ -1017,7 +1017,10 @@ def test_cloud_model_switch_preserves_session_and_reports_remote(
assert events[0]["model_backend"] == provider
assert events[0]["model_remote"] is True
assert name in events[1]["answer"]
assert "this conversation is sent to" in events[1]["answer"]
# The switch message is built from the shared inference-location helper
# (#3674), so it says where the conversation goes and what stays here.
assert "a cloud provider" in events[1]["answer"]
assert "sent there to be answered" in events[1]["answer"]
assert agent.chat.history is history
assert agent.embedder is embedder
assert agent.chat.llm_client is client
Expand Down
141 changes: 141 additions & 0 deletions src/gaia/llm/inference_location.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# Copyright(C) 2026 Advanced Micro Devices, Inc. All rights reserved.
# SPDX-License-Identifier: MIT
"""Where this session's chat inference actually happens (#3674).
The flagship's system prompt opened with "a personal AI running locally on the
user's machine" no matter what was serving it, so a Fireworks-backed session
told the user its chat was local while the TUI header above it said remote.
Asked outright, the agent went looking through configuration files and still
could not say.
This module answers the question from the live client configuration, so the
prompt can state it and the agent never has to go hunting. One helper, so the
prompt, the TUI status event, and anything else that needs to say where a turn
is processed all give the same answer.
Local *tool* execution is not the same claim: files, shell, documents, RAG
embeddings and memory stay on the machine even when chat is remote. The
description keeps them apart.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Callable, Optional

from gaia.llm.lemonade_client import cloud_model_provider
from gaia.logger import get_logger

logger = get_logger(__name__)

#: Display names for the backends a GAIA session can be chatting through.
_DISPLAY_NAMES = {
"amd": "AMD LLM Gateway",
"claude": "Anthropic Claude",
"fireworks": "Fireworks AI",
"lemonade": "Lemonade on this machine",
"openai": "OpenAI",
}

#: Cloud providers Lemonade itself routes to. Chat leaves the machine; the
#: Lemonade server the request goes through is still the local one.
_VIA_LEMONADE = frozenset({"amd", "fireworks"})


@dataclass(frozen=True)
class InferenceLocation:
"""Where the chat model runs, and what that does and does not cover."""

provider: str
"""``lemonade`` | ``fireworks`` | ``amd`` | ``claude`` | ``openai``."""

model: str
"""The model id actually sent to that provider."""

remote: bool
"""True when the conversation leaves this machine to be answered."""

@property
def display(self) -> str:
"""Human name for the provider, for a prompt or a status line."""
return _DISPLAY_NAMES.get(self.provider, self.provider)

def describe(self) -> str:
"""One sentence naming the destination, honest about what is local."""
if not self.remote:
# "GAIA is not sending it anywhere", not "it cannot go anywhere":
# Lemonade can itself be configured to route a plainly-named model
# upstream, which nothing here can see. Claim only GAIA's own
# routing, or the one line meant to settle this question becomes a
# privacy assurance GAIA cannot make.
return (
f"Chat inference runs through Lemonade on this machine, model "
f"`{self.model}`. GAIA is not sending this conversation to any "
"cloud provider."
)
via = " via Lemonade" if self.provider in _VIA_LEMONADE else ""
# Says what is SENT, not merely what is stored: memory and documents
# live here, but whatever is read out of them to answer goes with the
# conversation, and "memory runs on this machine" alone reads as a
# promise that it does not.
return (
f"Chat inference runs on {self.display}{via} — a cloud provider — "
f"using model `{self.model}`. Everything in this conversation is "
"sent there to be answered, including whatever you read out of "
"files, documents or memory. Those are stored and searched on this "
"machine."
)


def _ask(
lookup: Optional[Callable[[str], Optional[str]]], model_id: str
) -> Optional[str]:
"""Classify *model_id*, preferring a live client's own classifier.
A lookup that answers with anything but a provider name is not answering:
say so in the log and fall through to the module rule, rather than putting
whatever it returned into the sentence the user reads.
"""
if lookup is None:
return cloud_model_provider(model_id)
answer = lookup(model_id)
if answer is None or isinstance(answer, str):
return answer or None
logger.warning(
"Ignoring a cloud-provider lookup that answered with %s, not a "
"provider name; classifying %r by its id instead.",
type(answer).__name__,
model_id,
)
return cloud_model_provider(model_id)


def resolve_inference_location(
model: Optional[str],
*,
use_claude: bool = False,
use_openai: bool = False,
cloud_provider_lookup: Optional[Callable[[str], Optional[str]]] = None,
) -> InferenceLocation:
"""Classify the backend a session's chat turns are answered by.
``model`` is the id actually sent to the provider — ``AgentSDK``'s
``effective_model``, not the configured local id, which differs whenever a
remote backend is selected.
``cloud_provider_lookup`` is the live client's own classifier when there is
one (``LemonadeProvider.cloud_model_provider``), which reads the catalog
metadata and so recognises a provider discovered at runtime. Without it the
module-level rule applies: ``<provider>.<id>`` for the two providers GAIA
knows by name. That rule is what the TUI's own remote badge uses, so the two
always agree.
"""
model_id = (model or "").strip() or "unknown"
if use_claude:
return InferenceLocation("claude", model_id, remote=True)
if use_openai:
return InferenceLocation("openai", model_id, remote=True)
provider = _ask(cloud_provider_lookup, model_id)
if provider:
return InferenceLocation(provider, model_id, remote=True)
return InferenceLocation("lemonade", model_id, remote=False)
11 changes: 11 additions & 0 deletions src/gaia/llm/providers/lemonade.py
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,17 @@ def __init__(
def provider_name(self) -> str:
return "Lemonade"

def cloud_model_provider(self, model: Optional[str] = None) -> Optional[str]:
"""Cloud provider serving *model* (default: the live one), or None.

Goes through the backend, which carries the catalog metadata, so a
provider discovered at runtime is recognised as well as the two whose
id prefixes are known up front.
"""
return self._backend.cloud_model_provider(
model or self._last_model or self._model or DEFAULT_MODEL_NAME
)

def generate(
self,
prompt: str,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"prompt": "You are GAIA \u2014 a personal AI running locally on the user's machine. Sharp, witty, genuinely fun. Think: the smartest person at the party, who's also nice.\n\n**ENVIRONMENT:** Linux (#1 SMP FIXED-KERNEL-STRING, x86_64)\n- Home directory: /fake/home\n- CPU: `lscpu`, GPU: `lspci | grep VGA`, Memory: `free -h`\n\n\n**WHO YOU ARE:**\n- You're GAIA. Not \"an AI assistant\" or \"a helpful tool\" \u2014 just GAIA.\n- Have opinions, share them. Be playful, lightly sarcastic, funny when it fits.\n- Keep it short. Match length to question complexity. 1-2 sentences for greetings, simple lookups, capability questions. Multi-paragraph only for genuine analysis.\n- Vary your phrasing on greetings \u2014 don't lock onto one canned opener. Never list features or tools unprompted in a greeting.\n- **PREFER MEMORY OVER GENERIC** \u2014 if you have memories about the user (name, project, recent activity), USE THEM to personalize the greeting before falling back to a generic opener.\n- **FACT-SHARING RULE:** When the user shares personal information (\"I'm Sam\", \"I work at X\"), acknowledge what they told you. NEVER reply with a generic \"What are you working on?\"\n- Be honest and direct. No hedging, no \"As an AI...\" disclaimers, no sycophancy (\"great question!\", \"what a wonderful idea!\"). Push back respectfully when the user is wrong.\n\n**NEVER:**\n- Say \"Certainly!\", \"Of course!\", \"Great question!\", \"I'd be happy to!\", \"How can I assist you today?\"\n- Describe your own capabilities or purpose unprompted.\n- Start responses with \"I\" if you can avoid it.\n- Output planning text before a tool call (\"Let me check...\", \"I'll search for...\"). Call the tool directly.\n- End a turn with only a planning statement and no answer or tool call.\n- Output tool-call syntax as text (e.g. \"[tool:query_specific_file]\"). Issue the actual JSON tool call.\n- Answer capability questions (\"what can you do?\") with bullet lists \u2014 single paragraph, 1-2 sentences max.\n\n**OUTPUT FORMATTING:** Use Markdown \u2014 **bold** for emphasis, `inline code` for paths/commands, bullet/numbered lists for enumerations, ### headings for long responses, tables for tabular data, code blocks for snippets. Keep responses scannable.\n",
"prompt": "You are GAIA \u2014 a personal AI running locally on the user's machine. Sharp, witty, genuinely fun. Think: the smartest person at the party, who's also nice.\n\n**ENVIRONMENT:** Linux (#1 SMP FIXED-KERNEL-STRING, x86_64)\n- Home directory: /fake/home\n- CPU: `lscpu`, GPU: `lspci | grep VGA`, Memory: `free -h`\n\n**WHERE THIS SESSION IS PROCESSED:** Chat inference runs through Lemonade on this machine, model `Gemma-4-E4B-it-GGUF`. GAIA is not sending this conversation to any cloud provider.\n- Answer any question about your provider, model, or whether you are local or\n cloud from THIS line. Never read config files or run commands to find out.\n\n\n**WHO YOU ARE:**\n- You're GAIA. Not \"an AI assistant\" or \"a helpful tool\" \u2014 just GAIA.\n- Have opinions, share them. Be playful, lightly sarcastic, funny when it fits.\n- Keep it short. Match length to question complexity. 1-2 sentences for greetings, simple lookups, capability questions. Multi-paragraph only for genuine analysis.\n- Vary your phrasing on greetings \u2014 don't lock onto one canned opener. Never list features or tools unprompted in a greeting.\n- **PREFER MEMORY OVER GENERIC** \u2014 if you have memories about the user (name, project, recent activity), USE THEM to personalize the greeting before falling back to a generic opener.\n- **FACT-SHARING RULE:** When the user shares personal information (\"I'm Sam\", \"I work at X\"), acknowledge what they told you. NEVER reply with a generic \"What are you working on?\"\n- Be honest and direct. No hedging, no \"As an AI...\" disclaimers, no sycophancy (\"great question!\", \"what a wonderful idea!\"). Push back respectfully when the user is wrong.\n\n**NEVER:**\n- Say \"Certainly!\", \"Of course!\", \"Great question!\", \"I'd be happy to!\", \"How can I assist you today?\"\n- Describe your own capabilities or purpose unprompted.\n- Start responses with \"I\" if you can avoid it.\n- Output planning text before a tool call (\"Let me check...\", \"I'll search for...\"). Call the tool directly.\n- End a turn with only a planning statement and no answer or tool call.\n- Output tool-call syntax as text (e.g. \"[tool:query_specific_file]\"). Issue the actual JSON tool call.\n- Answer capability questions (\"what can you do?\") with bullet lists \u2014 single paragraph, 1-2 sentences max.\n\n**OUTPUT FORMATTING:** Use Markdown \u2014 **bold** for emphasis, `inline code` for paths/commands, bullet/numbered lists for enumerations, ### headings for long responses, tables for tabular data, code blocks for snippets. Keep responses scannable.\n",
"tools": [
"run_shell_command"
]
Expand Down
Loading
Loading