Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
5 changes: 5 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ jobs:
with:
languages: python
queries: security-and-quality
config: |
paths-ignore:
# grpcio-tools output — regenerated from connector.proto, not hand-edited
- '**/*_pb2.py'
- '**/*_pb2_grpc.py'

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@8272c299f21ca24af15dfe9ac0971ba969e5e0d5 # v3.36.2
Expand Down
10 changes: 5 additions & 5 deletions playground/scenario_post_visit.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,23 +75,23 @@ async def run_scenario():

print("\n=== STEP 1: Patient Discovery ===")
patient_search_params = {"family": "Smith", "given": "Jason", "birthdate": "1985-01-01"}
logger.info(f"Searching for patient: {patient_search_params}")
logger.info("Searching for patient by fields: %s", ", ".join(sorted(patient_search_params)))

try:
patient_result = await connector.internal_execute(
FhirPatientReadInput(action="read_patient", search_params=patient_search_params),
trace_id=trace_id,
)
patient_id = patient_result.resource.get("id")
logger.info(f"Found Patient ID: {patient_id}")
logger.info("Patient resolved successfully")
except Exception as e:
logger.error(f"Patient search failed: {e}")
return

print("\n=== STEP 2: Encounter Identification ===")
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
encounter_params = {"patient": patient_id, "status": "finished", "date": today}
logger.info(f"Finding encounter for patient {patient_id} on {today}")
logger.info(f"Finding encounter for resolved patient on {today}")

try:
enc_result = await connector.internal_execute(
Expand All @@ -116,7 +116,7 @@ async def run_scenario():
return

encounter_id = enc_result.resources[0].get("id")
logger.info(f"Selected Encounter ID: {encounter_id}")
logger.info("Encounter selected")
except Exception as e:
logger.error(f"Encounter search failed: {e}")
return
Expand All @@ -142,7 +142,7 @@ async def run_scenario():
context={"encounter": [{"reference": f"Encounter/{encounter_id}"}]},
)

logger.info(f"Uploading clinical note for Encounter {encounter_id}")
logger.info("Uploading clinical note for selected encounter")
try:
doc_result = await connector.internal_execute(doc_input, trace_id=trace_id)
logger.info(f"SUCCESS! Created DocumentReference: {doc_result.resource_id}")
Expand Down
83 changes: 54 additions & 29 deletions playground/scenarios.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from pydantic import BaseModel, ValidationError, model_validator
from dotenv import load_dotenv
import os
import sys
import asyncio
from node_wire_runtime.errors import ErrorMapper
from node_wire_runtime.models import ErrorCategory
Expand Down Expand Up @@ -252,7 +253,6 @@ def _safe_error_return(
) -> ScenarioResponse:
from node_wire_runtime.errors import ErrorMapper
from node_wire_runtime.models import ErrorCategory
import logging

log = logging.getLogger("playground.scenarios")

Expand All @@ -266,8 +266,8 @@ def _safe_error_return(
if hasattr(e, "errors") and callable(getattr(e, "errors", None)):
try:
safe_msg = e.errors()[0].get("msg", "Schema validation failed")
except Exception:
pass
except Exception as detail_exc:
log.debug("Could not extract validation error detail: %s", detail_exc)

steps[-1].status = "error"
steps[-1].details = f"[{mapped_err.category.value}] {safe_msg}"
Expand Down Expand Up @@ -312,6 +312,12 @@ async def execute_with_retry(
else:
logger.error(f"Action failed after {max_retries + 1} attempts: {e}")
raise last_exception
# Only reachable when max_retries < 0 leaves the loop with zero iterations.
raise (
last_exception
if last_exception
else RuntimeError(f"execute_with_retry made no attempts (max_retries={max_retries})")
)


# Single shared factory for playground scenarios (matches REST: enabled + exposed_via includes "rest").
Expand Down Expand Up @@ -408,7 +414,7 @@ def add_step(
add_step("Patient Discovery", "pending", display_name="Identify Patient")
try:
if payload.patient_id:
logger.info(f"Performing direct Patient ID lookup: {payload.patient_id}")
logger.info("Performing direct Patient ID lookup")
p_res = await execute_with_retry(
connector, FhirPatientReadInput(resource_id=payload.patient_id), trace_id, steps[-1]
)
Expand All @@ -423,7 +429,17 @@ def add_step(
}.items()
if v is not None
}
logger.info(f"Searching for patient: {patient_search_params}")
# Log only literal field names (not the payload-derived dict) so no
# user-controlled data reaches the log record.
provided_fields = {
"family": payload.patient_family is not None,
"given": payload.patient_given is not None,
"birthdate": payload.patient_birthdate is not None,
}
logger.info(
"Searching for patient by fields: %s",
", ".join(sorted(k for k, present in provided_fields.items() if present)),
)
p_res = await execute_with_retry(
connector,
FhirPatientReadInput(search_params=patient_search_params),
Expand Down Expand Up @@ -455,16 +471,15 @@ def add_step(
add_step("Encounter Identification", "pending", display_name="Locate Medical Visit")
try:
if payload.encounter_id:
logger.info(
f"Using manual Encounter ID: {payload.encounter_id}", extra={"trace_id": trace_id}
)
logger.info("Using manually supplied Encounter ID", extra={"trace_id": trace_id})
encounter_id = payload.encounter_id
enc_type = "Manual"
enc_status = "verified"
else:
visit_date = payload.visit_date or datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
logger.info(
f"Searching for encounter... patient={patient_id}, date={visit_date}",
"Searching for encounter for resolved patient on date=%s",
visit_date.replace("\r", "").replace("\n", ""),
extra={"trace_id": trace_id},
)
enc_res = await execute_with_retry(
Expand Down Expand Up @@ -505,7 +520,7 @@ def add_step(
raise ValueError("The found Encounter resource is missing a valid FHIR ID.")

logger.info(
f"Selected Encounter: ID={encounter_id}, Type={enc_type}, Status={enc_status}",
f"Selected Encounter: Type={enc_type}, Status={enc_status}",
extra={"trace_id": trace_id},
)

Expand Down Expand Up @@ -717,8 +732,6 @@ def add_step(
http_action = connector
response = await execute_with_retry(http_action, request_input, trace_id, steps[-1])

import json

resp_body = json.loads(response.body)

steps[-1].status = "success"
Expand Down Expand Up @@ -760,8 +773,6 @@ def add_step(
add_step("Audit", "pending", display_name="Update Audit Log")
try:
# Simulate background task
import asyncio

await asyncio.sleep(0.4)

steps[-1].status = "success"
Expand Down Expand Up @@ -800,7 +811,7 @@ def add_step(
add_step("Patient Discovery", "pending", display_name="Identify Patient")
try:
if payload.patient_id:
logger.info(f"Cerner: direct Patient ID lookup: {payload.patient_id}")
logger.info("Cerner: direct Patient ID lookup")
p_res = await execute_with_retry(
connector,
FhirCernerPatientReadInput(resource_id=payload.patient_id),
Expand All @@ -818,7 +829,17 @@ def add_step(
}.items()
if v
}
logger.info(f"Cerner: searching for patient: {search_params}")
# Log only literal field names (not the payload-derived dict) so no
# user-controlled data reaches the log record.
provided_fields = {
"family": bool(payload.patient_family),
"given": bool(payload.patient_given),
"birthdate": bool(payload.patient_birthdate),
}
logger.info(
"Cerner: searching for patient by fields: %s",
", ".join(sorted(k for k, present in provided_fields.items() if present)),
)
p_res = await execute_with_retry(
connector,
FhirCernerPatientReadInput(search_params=search_params),
Expand Down Expand Up @@ -1009,8 +1030,8 @@ def add_step(
decoded_text = base64.b64decode(content[0]["attachment"]["data"]).decode(
"utf-8"
)
except Exception:
pass
except Exception as decode_exc:
logger.debug("Could not decode attachment content: %s", decode_exc)

beautiful_data = {
"id": doc_res.resource_id,
Expand Down Expand Up @@ -1852,9 +1873,6 @@ async def agent_chat(payload: AgentChatInput) -> AgentChatResponse:
Accepts a user message + conversation history, runs through the ToolHiveAgent,
and returns the agent's reply with any tool steps executed.
"""
import os
import sys

trace_id = str(uuid.uuid4())
logger.info(
"Agent Chat request | trace_id=%s | provider=%s",
Expand Down Expand Up @@ -2009,8 +2027,6 @@ async def agent_chat_stream(payload: AgentChatInput) -> Any:

async def stream_events():
try:
import sys

from agents.llm_factory import LLMProviderFactory
from agents.toolhive import (
MultiMcpClient,
Expand Down Expand Up @@ -2079,13 +2095,16 @@ async def stream_events():
yield json.dumps(event) + "\n"

except Exception as exc:
logger.error("Agent Chat stream failed: %s", exc, exc_info=True)
trace_id = str(uuid.uuid4())
logger.error("Agent Chat stream failed (trace_id=%s): %s", trace_id, exc, exc_info=True)
yield (
json.dumps(
{
"type": "final_chunk",
"content": f"Sorry, I encountered an error: {exc}. Please check the server configuration and try again.",
"content": (
"Sorry, I encountered an internal error. "
f"Please check the server configuration and try again. trace_id={trace_id}"
),
}
)
+ "\n"
Expand Down Expand Up @@ -2451,8 +2470,7 @@ def add_step(
try:
if payload.patient_id:
logger.info(
"[ExtViewer] Direct Patient ID lookup: %s on %s",
payload.patient_id,
"[ExtViewer] Direct Patient ID lookup on %s",
system_label,
extra={"trace_id": trace_id},
)
Expand Down Expand Up @@ -2488,9 +2506,16 @@ def add_step(
}.items()
if v
}
# Log only literal field names (not the payload-derived dict) so no
# user-controlled data reaches the log record.
provided_fields = {
"family": bool(payload.patient_family),
"given": bool(payload.patient_given),
"birthdate": bool(payload.patient_birthdate),
}
logger.info(
"[ExtViewer] Identity-layer search: %s on %s",
search_params,
"[ExtViewer] Identity-layer search by fields [%s] on %s",
", ".join(sorted(k for k, present in provided_fields.items() if present)),
system_label,
extra={"trace_id": trace_id},
)
Expand Down
89 changes: 89 additions & 0 deletions src/agents/llm_base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
#
# SPDX-FileCopyrightText: 2026 AOT Technologies
# SPDX-License-Identifier: Apache-2.0
#
"""
LLM Provider Base
=================
Provider-agnostic data models and the abstract provider interface.

Kept separate from :mod:`agents.llm_factory` so provider implementations can
depend on the interface without importing the factory (which imports the
providers), avoiding a module-level import cycle.
"""

from __future__ import annotations

from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional


# ---------------------------------------------------------------------------
# Data models (provider-agnostic)
# ---------------------------------------------------------------------------


@dataclass
class ToolCall:
"""A single tool-call request returned by the LLM."""

id: str
name: str
arguments: Dict[str, Any]


@dataclass
class LLMMessage:
"""A single message in the conversation thread."""

role: str # "system" | "user" | "assistant" | "tool"
content: Optional[str] = None
tool_calls: List[ToolCall] = field(default_factory=list)
tool_call_id: Optional[str] = None # required for role="tool" responses
name: Optional[str] = None # tool name for role="tool"


@dataclass
class LLMResponse:
"""Raw response from the LLM."""

content: Optional[str]
tool_calls: List[ToolCall] = field(default_factory=list)
stop_reason: str = "stop" # "stop" | "tool_calls"

@property
def wants_tool_call(self) -> bool:
return bool(self.tool_calls)


# ---------------------------------------------------------------------------
# Abstract base
# ---------------------------------------------------------------------------


class BaseLLMProvider(ABC):
"""Common interface for all LLM providers."""

@abstractmethod
def chat_with_tools(
self,
messages: List[LLMMessage],
tools: List[Dict[str, Any]],
) -> LLMResponse:
"""
Send a conversation to the LLM, optionally with a set of tools.

Parameters
----------
messages:
Full conversation history in provider-agnostic format.
tools:
List of MCP-style tool objects with ``name``, ``description``,
and ``input_schema`` keys.

Returns
-------
LLMResponse
The model's response, which may include tool_calls.
"""
Loading
Loading