Skip to content
Open
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
143 changes: 132 additions & 11 deletions planetary-explorer/container-app/agents/analyst_agent/analyst_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,10 @@ class AnalystAgent:
def __init__(self) -> None:
self._agents_client = None
self._agent_id: Optional[str] = None
self._current_model: Optional[str] = None
self._initialized = False
self._init_lock = asyncio.Lock()
self._model_lock = asyncio.Lock()
self._threads: Dict[str, AnalystThread] = {}
self._max_init_retries = 2
logger.info("AnalystAgent created (lazy init on first use)")
Expand Down Expand Up @@ -113,7 +115,7 @@ async def _ensure_initialized(self) -> None:
assert last_error is not None
raise last_error

async def _do_initialize(self) -> None:
async def _do_initialize(self, model: Optional[str] = None) -> None:
if not _AZURE_AVAILABLE:
raise RuntimeError("azure.identity not installed")

Expand All @@ -123,7 +125,11 @@ async def _do_initialize(self) -> None:
endpoint = os.getenv("AZURE_AI_PROJECT_ENDPOINT") or os.getenv(
"AZURE_OPENAI_ENDPOINT"
)
deployment = os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME", "gpt-5")
# `model` is the user-selected deployment (from the frontend's model
# selector, threaded in via AnalyzeAgent.run -> AnalystAgent.run).
# Falls back to the env-configured default when no selection was
# made, preserving prior behavior for callers that don't pass one.
deployment = model or os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME", "gpt-5")
if not endpoint:
raise ValueError(
"AZURE_AI_PROJECT_ENDPOINT or AZURE_OPENAI_ENDPOINT must be set"
Expand All @@ -140,18 +146,105 @@ async def _do_initialize(self) -> None:
toolset.add(functions)
self._agents_client.enable_auto_function_calls(toolset)

agent = await self._agents_client.create_agent(
model=deployment,
name="PlanetaryExplorerAnalyst",
instructions=ANALYST_AGENT_INSTRUCTIONS,
toolset=toolset,
)
agent = await self._create_agent_capped(deployment, ANALYST_AGENT_INSTRUCTIONS, toolset)
self._agent_id = agent.id
self._current_model = deployment
self._initialized = True
logger.info(
"AnalystAgent initialized: agent_id=%s model=%s", agent.id, deployment
)

async def _ensure_model(self, model: Optional[str]) -> None:
"""Ensure the live agent definition uses ``model``, if given.

AnalystAgent is a long-lived singleton whose Azure AI Agent Service
agent definition is created once (model baked in at creation time).
Unlike ``semantic_translator.set_model()`` -- which just resets a
Semantic Kernel wrapper and lazily rebuilds it -- the Agent Service
SDK binds ``model`` at ``create_agent()`` time, so switching models
means recreating the agent definition.

Threads are NOT tied to a specific agent definition (a thread_id is
passed alongside agent_id on every run), so recreating the agent
preserves all existing per-session conversation threads -- this is
cheap (a single API call) and doesn't lose any session state.

No-op when ``model`` is falsy or already matches the live agent, so
the common case (no selection made, or same model as last turn)
costs nothing.
"""
if not model:
return
await self._ensure_initialized()
if model == self._current_model:
return
async with self._model_lock:
if model == self._current_model:
return
from .analyst_prompt import ANALYST_AGENT_INSTRUCTIONS
from .tools import create_analyst_functions
from azure.ai.agents.models import AsyncFunctionTool, AsyncToolSet # type: ignore

logger.info(
"[ANALYST] Switching model %s -> %s (recreating agent, threads preserved)",
self._current_model, model,
)
functions = AsyncFunctionTool(create_analyst_functions())
toolset = AsyncToolSet()
toolset.add(functions)
self._agents_client.enable_auto_function_calls(toolset) # type: ignore[union-attr]
agent = await self._create_agent_capped(model, ANALYST_AGENT_INSTRUCTIONS, toolset)
self._agent_id = agent.id
self._current_model = model

async def _create_agent_capped(self, model: str, instructions: str, toolset):
"""Create an Agent Service agent, capping reasoning effort for gpt-5-
family models when possible.

The Agents SDK's ``create_agent`` does not document a
``reasoning_effort`` parameter the way the plain chat.completions
API does (see ContextualAgent/LoadAgent/etc., which already cap
this on gpt-5 for latency). Since the underlying REST API is built
on the same schema family as the Assistants/Responses API, it may
still accept the field as an undocumented passthrough -- but this
is unverified, so we try it and transparently fall back to
creating the agent without it if the service/SDK rejects the
extra kwarg. Never raises solely because of this attempt.
"""
model_lc = (model or "").lower()
is_reasoning = model_lc.startswith(("gpt-5", "o1", "o3", "o4"))
if is_reasoning:
try:
return await self._agents_client.create_agent( # type: ignore[union-attr]
model=model,
name="PlanetaryExplorerAnalyst",
instructions=instructions,
toolset=toolset,
reasoning_effort="minimal",
)
except TypeError:
# SDK's typed signature rejects the kwarg outright (no
# passthrough support at all in this SDK version).
logger.info(
"[ANALYST] reasoning_effort not supported by installed "
"azure-ai-agents SDK version -- creating agent without it"
)
except Exception as exc:
# Service-side rejection (e.g. unknown field in request
# body) surfaces as an HTTP error from the SDK, not a
# TypeError -- catch broadly here so a latency-cap attempt
# never breaks agent creation.
logger.info(
"[ANALYST] reasoning_effort rejected by service (%s) -- "
"creating agent without it", exc,
)
return await self._agents_client.create_agent( # type: ignore[union-attr]
model=model,
name="PlanetaryExplorerAnalyst",
instructions=instructions,
toolset=toolset,
)

# ------------------------------------------------------------------
# Thread management
# ------------------------------------------------------------------
Expand All @@ -171,12 +264,19 @@ async def _get_or_create_thread(self, session_id: str) -> AnalystThread:
# Main entry
# ------------------------------------------------------------------

async def run(self, request) -> "SynthesizedResponse":
async def run(self, request, model: Optional[str] = None) -> "SynthesizedResponse":
"""Run the ReAct loop for a single AnalysisRequest.

Returns a SynthesizedResponse that's drop-in compatible with the
old Orchestrator + Synthesizer output (so layer1_agents.AnalyzeAgent
doesn't need to change its caller contract).

Args:
model: Optional user-selected deployment name (from the
frontend model selector). When given and different from
the live agent's current model, the agent definition is
recreated on this model before the run -- see
``_ensure_model``.
"""
from pipeline.contracts import (
AnalysisPlan,
Expand All @@ -187,6 +287,7 @@ async def run(self, request) -> "SynthesizedResponse":
from .session_context import AnalystSession, clear_session, get_session, set_session

started = time.time()
await self._ensure_model(model)

# Populate the ContextVar so tools see the session.
# ``use_graphrag`` / ``stac_mode`` ride on the request via the
Expand Down Expand Up @@ -217,12 +318,13 @@ async def run(self, request) -> "SynthesizedResponse":
set_session(sess)

try:
answer, tool_calls, evidence = await self._invoke_agent_service(request)
answer, tool_calls, evidence, skill_pack_id = await self._invoke_agent_service(request)
except Exception as e:
logger.exception("[ANALYST] run failed, returning fallback response")
answer = self._fallback_answer(request, str(e))
tool_calls = []
evidence = []
skill_pack_id = None

# Aggregate sources from tool evidence
sources: List[Source] = []
Expand Down Expand Up @@ -252,6 +354,9 @@ async def run(self, request) -> "SynthesizedResponse":
if clarify_payload:
structured_by_tool["clarify"] = clarify_payload

if skill_pack_id:
structured_by_tool["skill_pack"] = {"id": skill_pack_id}

# Build a degenerate plan record for back-compat with callers that
# still serialize ``plan``. The plan is just the sequence of tools
# that actually ran.
Expand Down Expand Up @@ -298,6 +403,21 @@ async def _invoke_agent_service(self, request):

augmented = self._build_message(request)

# Skill pack: pick 0-1 domain guidance packs matching this question
# and inject as additional_instructions for this run only -- pure
# prompt augmentation, no new tools, no model change. Mirrors the
# identical mechanism in EnhancedVisionAgent.analyze().
skill_instructions = None
skill_pack_id = None
try:
from skills.skill_selector import select_skill_pack, render_skill_block
pack = select_skill_pack(request.question, applies_to="analyst_agent")
if pack:
skill_instructions = render_skill_block(pack)
skill_pack_id = pack.id
except Exception as skill_exc:
logger.warning("[SKILLS] Selection failed (continuing without): %s", skill_exc)

run = None
for attempt in range(3):
try:
Expand All @@ -317,6 +437,7 @@ async def _invoke_agent_service(self, request):
run = await self._agents_client.runs.create_and_process(
thread_id=thread.thread_id,
agent_id=self._agent_id,
additional_instructions=skill_instructions,
)
break
except Exception as e:
Expand Down Expand Up @@ -359,7 +480,7 @@ async def _invoke_agent_service(self, request):
# Tools recorded their results on the session ContextVar
from .session_context import get_session
evidence = list(get_session().evidence)
return answer, tool_calls, evidence
return answer, tool_calls, evidence, skill_pack_id

# ------------------------------------------------------------------
# Helpers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,13 +118,18 @@ def __init__(
"AZURE_OPENAI_CLARIFIER_DEPLOYMENT",
os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME", "gpt-5"),
)
self.endpoint = endpoint or os.getenv(
"AZURE_AI_PROJECT_ENDPOINT"
) or os.getenv("AZURE_OPENAI_ENDPOINT")
# NOTE: ClarifierAgent talks to AzureOpenAI's plain chat-completions
# API (not the Agent Service), so it must use the Cognitive
# Services endpoint/token audience, not AZURE_AI_PROJECT_ENDPOINT
# (an ai.azure.com Agent Service project endpoint used elsewhere,
# e.g. by AnalystAgent). Using the project endpoint here caused a
# silent 401 "audience is incorrect (https://ai.azure.com)" on
# every greeting/identity short-circuit -- the code fell back to
# a static passthrough on error, which masked the failure.
self.endpoint = endpoint or os.getenv("AZURE_OPENAI_ENDPOINT")
if not self.endpoint:
raise ValueError(
"ClarifierAgent requires AZURE_AI_PROJECT_ENDPOINT or "
"AZURE_OPENAI_ENDPOINT to be set."
"ClarifierAgent requires AZURE_OPENAI_ENDPOINT to be set."
)
self.api_version = api_version
self._client: Optional[AsyncAzureOpenAI] = None
Expand Down Expand Up @@ -155,13 +160,26 @@ def _get_client(self) -> AsyncAzureOpenAI:
# ------------------------------------------------------------------
# Public entry point
# ------------------------------------------------------------------
async def decide(self, payload: ClarifierInput) -> ClarifierDecision:
async def decide(
self, payload: ClarifierInput, model: Optional[str] = None
) -> ClarifierDecision:
"""
Run the clarifier prompt and return a ClarifierDecision.

Args:
payload: The clarifier input.
model: Optional per-call model override (the frontend's
model selector). Falls back to ``self.deployment`` (the
env-configured default) when not given, preserving prior
behavior for callers that don't pass one. This mirrors
the fix already applied to AnalystAgent.run(model=...) --
without it, the greeting/identity short-circuit silently
ignored the user's model selection.

Falls back to a deterministic passthrough on any error so the
request keeps working even if the LLM is unreachable.
"""
_deployment = model or self.deployment
try:
pin_lat_lng = (
f"({payload.pin_lat:.4f}, {payload.pin_lng:.4f})"
Expand All @@ -183,8 +201,13 @@ async def decide(self, payload: ClarifierInput) -> ClarifierDecision:
)

client = self._get_client()
_model_lc = (_deployment or "").lower()
_is_reasoning = _model_lc.startswith(("gpt-5", "o1", "o3", "o4"))
_sampling_kwargs = (
{"reasoning_effort": "minimal"} if _is_reasoning else {"temperature": 0.0}
)
response = await client.chat.completions.create(
model=self.deployment,
model=_deployment,
messages=[
{"role": "system", "content": CLARIFIER_SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
Expand All @@ -193,8 +216,7 @@ async def decide(self, payload: ClarifierInput) -> ClarifierDecision:
"type": "json_schema",
"json_schema": CLARIFIER_DECISION_SCHEMA,
},
temperature=0.0,
reasoning_effort="minimal",
**_sampling_kwargs,
)

content = response.choices[0].message.content or "{}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,13 +107,24 @@ async def run(self, payload: ContextualInput) -> ContextualResult:

try:
client = self._get_client()
# NOTE: temperature intentionally omitted. gpt-5 reasoning
# deployments only accept the default temperature (1); passing
# any other value returns HTTP 400 and bubbles up as the
# user-visible "LLM call failed" sentinel.
# Model-aware kwargs: gpt-5 / o-series reasoning models reject
# `temperature` (only default=1 supported) but accept
# `reasoning_effort`. Without capping reasoning_effort, gpt-5
# burns many hidden reasoning tokens even on simple factual
# questions -- measured adding ~10-15s of latency to plain
# "what is NDVI"-style questions. Mirrors the same model-aware
# pattern used by LoadAgent / ClarifierAgent / CollectionSelector.
model_lc = (self.deployment or "").lower()
is_reasoning = model_lc.startswith(("gpt-5", "o1", "o3", "o4"))
extra: dict = {}
if is_reasoning:
extra["reasoning_effort"] = "minimal"
else:
extra["temperature"] = 0.3
resp = await client.chat.completions.create(
model=self.deployment,
messages=messages,
**extra,
)
answer = resp.choices[0].message.content or ""
except Exception as exc: # noqa: BLE001
Expand Down
Loading