bug fix sending component

This commit is contained in:
2026-03-10 09:11:24 +01:00
parent 618076193a
commit 9332e29e53
9 changed files with 109 additions and 6 deletions

1
.gitignore vendored
View File

@@ -31,3 +31,4 @@ Thumbs.db
# Claude Code # Claude Code
.claude/ .claude/
logs/

View File

@@ -233,10 +233,19 @@ async def _handle_home_request(
executor = await _make_ws_executor(websocket, user_id) executor = await _make_ws_executor(websocket, user_id)
set_client_executor(executor) set_client_executor(executor)
response_chunks: list[str] = [] response_chunks: list[str] = []
agent_holder: list = []
try: try:
token_stream = orchestrate_v3_stream(user_id, message, context) token_stream = orchestrate_v3_stream(
user_id, message, context, agent_holder=agent_holder
)
formatter = HomeFormatter(request_id=request_id, tool_results=[]) formatter = HomeFormatter(request_id=request_id, tool_results=[])
async for ws_frame in formatter.format(token_stream): async for ws_frame in formatter.format(token_stream):
# Inject mutations from agent tool_results into stream_end
if ws_frame.type == "stream_end" and agent_holder: # type: ignore[union-attr]
ws_frame.mutations = [ # type: ignore[union-attr]
{"action": r["action"], "table": r["table"], "data": r["data"]}
for r in getattr(agent_holder[0], "tool_results", [])
]
await websocket.send_text(ws_frame.model_dump_json()) await websocket.send_text(ws_frame.model_dump_json())
# Collect text chunks to build the full response for episode storage # Collect text chunks to build the full response for episode storage
if ws_frame.type == "stream_text": # type: ignore[union-attr] if ws_frame.type == "stream_text": # type: ignore[union-attr]
@@ -278,10 +287,18 @@ async def _handle_floating_request(
executor = await _make_ws_executor(websocket, user_id) executor = await _make_ws_executor(websocket, user_id)
set_client_executor(executor) set_client_executor(executor)
response_chunks: list[str] = [] response_chunks: list[str] = []
agent_holder: list = []
try: try:
token_stream = orchestrate_v3_stream(user_id, message, context) token_stream = orchestrate_v3_stream(
user_id, message, context, agent_holder=agent_holder
)
formatter = FloatingFormatter(request_id=request_id) formatter = FloatingFormatter(request_id=request_id)
async for ws_frame in formatter.format(token_stream): async for ws_frame in formatter.format(token_stream):
if ws_frame.type == "stream_end" and agent_holder: # type: ignore[union-attr]
ws_frame.mutations = [ # type: ignore[union-attr]
{"action": r["action"], "table": r["table"], "data": r["data"]}
for r in getattr(agent_holder[0], "tool_results", [])
]
await websocket.send_text(ws_frame.model_dump_json()) await websocket.send_text(ws_frame.model_dump_json())
if ws_frame.type == "stream_text": # type: ignore[union-attr] if ws_frame.type == "stream_text": # type: ignore[union-attr]
response_chunks.append(ws_frame.chunk) # type: ignore[union-attr] response_chunks.append(ws_frame.chunk) # type: ignore[union-attr]

View File

@@ -23,10 +23,15 @@ from openai import AsyncOpenAI
import litellm import litellm
from langchain_openai import ChatOpenAI from langchain_openai import ChatOpenAI
from langchain_litellm import ChatLiteLLM
from litellm import get_supported_openai_params # noqa: F401 validates install from litellm import get_supported_openai_params # noqa: F401 validates install
from app.config.settings import settings from app.config.settings import settings
# Some models (e.g. gpt-5, o-series) reject unsupported params like temperature.
# Drop them silently instead of raising UnsupportedParamsError.
litellm.drop_params = True
def _api_key_for_model(model: str) -> str | None: def _api_key_for_model(model: str) -> str | None:
"""Return the most appropriate API key for the given LiteLLM model string.""" """Return the most appropriate API key for the given LiteLLM model string."""
@@ -48,7 +53,7 @@ def get_llm(
*, *,
model: str | None = None, model: str | None = None,
temperature: float = 0, temperature: float = 0,
) -> ChatOpenAI: ) -> ChatOpenAI | ChatLiteLLM:
"""Return a LangChain chat model backed by LiteLLM. """Return a LangChain chat model backed by LiteLLM.
LiteLLM exposes an OpenAI-compatible API, so we use ``ChatOpenAI`` pointed LiteLLM exposes an OpenAI-compatible API, so we use ``ChatOpenAI`` pointed
@@ -69,6 +74,11 @@ def get_llm(
if settings.GITHUB_COPILOT_TOKEN_DIR: if settings.GITHUB_COPILOT_TOKEN_DIR:
os.environ.setdefault("GITHUB_COPILOT_TOKEN_DIR", settings.GITHUB_COPILOT_TOKEN_DIR) os.environ.setdefault("GITHUB_COPILOT_TOKEN_DIR", settings.GITHUB_COPILOT_TOKEN_DIR)
# Use ChatLiteLLM for provider-prefixed models (github_copilot/, anthropic/, etc.)
# so LiteLLM handles routing and auth. ChatOpenAI for plain OpenAI model names.
if "/" in model:
return ChatLiteLLM(model=model, temperature=temperature)
return ChatOpenAI( return ChatOpenAI(
model=model, model=model,
temperature=temperature, temperature=temperature,
@@ -79,7 +89,7 @@ def get_llm(
def get_router_llm( def get_router_llm(
*, *,
temperature: float = 0, temperature: float = 0,
) -> ChatOpenAI: ) -> ChatOpenAI | ChatLiteLLM:
"""Return the lighter model used for intent classification / routing.""" """Return the lighter model used for intent classification / routing."""
return get_llm(model=settings.LLM_ROUTER_MODEL, temperature=temperature) return get_llm(model=settings.LLM_ROUTER_MODEL, temperature=temperature)

View File

@@ -162,17 +162,23 @@ async def orchestrate_v3_stream(
message: str, message: str,
context: dict[str, Any], context: dict[str, Any],
reg: AgentRegistry | None = None, reg: AgentRegistry | None = None,
agent_holder: list | None = None,
) -> AsyncGenerator[tuple[str, str], None]: ) -> AsyncGenerator[tuple[str, str], None]:
"""v3 streaming orchestration — yields (agent_name, token) pairs. """v3 streaming orchestration — yields (agent_name, token) pairs.
The first yield always carries the agent_name with an empty token so that The first yield always carries the agent_name with an empty token so that
callers (e.g. FloatingFormatter) can detect the routing domain before any text callers (e.g. FloatingFormatter) can detect the routing domain before any text
tokens arrive. tokens arrive.
If *agent_holder* is provided (a list), the agent instance is appended so
callers can access ``agent.tool_results`` after the stream completes.
""" """
if reg is None: if reg is None:
reg = _default_registry reg = _default_registry
agent_name = await classify_intent(message, context, reg) agent_name = await classify_intent(message, context, reg)
agent = reg.get(agent_name) agent = reg.get(agent_name)
if agent_holder is not None:
agent_holder.append(agent)
yield agent_name, "" # domain signal — no token yet yield agent_name, "" # domain signal — no token yet
async for token in agent.handle_stream(message, context): async for token in agent.handle_stream(message, context):
yield agent_name, token yield agent_name, token

View File

@@ -84,5 +84,9 @@ async def execute_on_client(
result = await callback(payload) result = await callback(payload)
collector = _tool_result_collector.get(None) collector = _tool_result_collector.get(None)
if collector is not None: if collector is not None:
collector.append(result) collector.append({
"action": action,
"table": table,
"data": result,
})
return result return result

View File

@@ -24,7 +24,7 @@ from app.config.settings import settings
engine = create_async_engine( engine = create_async_engine(
settings.DATABASE_URL, settings.DATABASE_URL,
pool_pre_ping=True, pool_pre_ping=True,
echo=settings.ENV == "dev", echo=False,
) )
async_session = async_sessionmaker(engine, expire_on_commit=False) async_session = async_sessionmaker(engine, expire_on_commit=False)

View File

@@ -1,8 +1,16 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import logging
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
logging.getLogger("sqlalchemy.pool").setLevel(logging.WARNING)
from app.api.middleware.rate_limit import TierRateLimitMiddleware from app.api.middleware.rate_limit import TierRateLimitMiddleware
from app.api.middleware.sanitizer import SanitizerMiddleware from app.api.middleware.sanitizer import SanitizerMiddleware
from app.config.settings import settings from app.config.settings import settings

56
logging.conf Normal file
View File

@@ -0,0 +1,56 @@
[loggers]
keys=root,uvicorn,uvicorn.error,uvicorn.access,sqlalchemy,watchfiles
[handlers]
keys=console,file
[formatters]
keys=default
[logger_root]
level=INFO
handlers=console,file
[logger_uvicorn]
level=INFO
handlers=
qualname=uvicorn
propagate=1
[logger_uvicorn.error]
level=INFO
handlers=
qualname=uvicorn.error
propagate=1
[logger_uvicorn.access]
level=INFO
handlers=
qualname=uvicorn.access
propagate=1
[logger_sqlalchemy]
level=WARNING
handlers=
qualname=sqlalchemy
propagate=1
[logger_watchfiles]
level=WARNING
handlers=
qualname=watchfiles
propagate=1
[handler_console]
class=StreamHandler
formatter=default
args=(sys.stderr,)
[handler_file]
class=logging.handlers.RotatingFileHandler
formatter=default
args=('logs/app.log', 'a', 10485760, 5, 'utf-8')
[formatter_default]
format=%(asctime)s %(levelname)s %(name)s: %(message)s
datefmt=%Y-%m-%d %H:%M:%S

View File

@@ -3,6 +3,7 @@ uvicorn[standard]>=0.34.0
gunicorn>=22.0.0 gunicorn>=22.0.0
langchain>=0.3.0 langchain>=0.3.0
langchain-openai>=0.3.0 langchain-openai>=0.3.0
langchain-litellm>=0.1.0
litellm>=1.50.0 litellm>=1.50.0
pydantic>=2.10.0 pydantic>=2.10.0
pydantic-settings>=2.7.0 pydantic-settings>=2.7.0