Skip to content

Commit eea9062

Browse files
committed
Improvements
1 parent 82f845d commit eea9062

7 files changed

Lines changed: 216 additions & 40 deletions

File tree

src/agent/components/nodes.py

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -88,16 +88,20 @@ async def run_summary_chain(state: OpeyGraphState):
8888

8989
messages = state["messages"]
9090

91-
# Build a copy of messages with oversized ToolMessage content truncated, so the
92-
# summarizer can't blow past the model's context window on a single huge tool result.
93-
# The original `messages` in state is unchanged; only the copy sent to the summarizer
94-
# is truncated. Summarization is compression, so truncated tool output is acceptable.
95-
MAX_TOOL_CONTENT_CHARS = 30000
91+
# The summarizer is the recovery path for an oversized conversation, so its OWN
92+
# input must be hard-bounded — otherwise summarizing a 375k-token thread sends
93+
# 375k tokens to the LLM and overflows (the failure this fixes). Truncate EVERY
94+
# message's content with a per-message cap that scales down as the message count
95+
# grows, bounding the total to ~SUMMARY_TOTAL_CHAR_BUDGET regardless of thread
96+
# size. No messages are dropped, so tool_use/tool_result pairing stays valid.
97+
# The original `messages` in state is unchanged; only this copy is truncated.
98+
SUMMARY_TOTAL_CHAR_BUDGET = 400_000 # ~100k tokens
99+
per_message_cap = max(200, SUMMARY_TOTAL_CHAR_BUDGET // max(len(messages), 1))
96100
messages_for_summary = []
97101
for msg in messages:
98-
if isinstance(msg, ToolMessage) and isinstance(msg.content, str) and len(msg.content) > MAX_TOOL_CONTENT_CHARS:
99-
truncated = msg.content[:MAX_TOOL_CONTENT_CHARS] + "\n\n[TRUNCATED TOOL RESPONSE FOR SUMMARIZATION]"
100-
messages_for_summary.append(msg.model_copy(update={"content": truncated}))
102+
new_content, was_truncated = _truncate_tool_content(msg.content, per_message_cap)
103+
if was_truncated:
104+
messages_for_summary.append(msg.model_copy(update={"content": new_content}))
101105
else:
102106
messages_for_summary.append(msg)
103107

@@ -115,6 +119,16 @@ async def run_summary_chain(state: OpeyGraphState):
115119
include_system=True
116120
)
117121

122+
# trim_messages returns an empty list when even the single most-recent message
123+
# exceeds max_tokens. That would delete the entire conversation — including the
124+
# user's current question — leaving the next Opey call with only the summary
125+
# SystemMessage, which Anthropic rejects ("at least one message is required").
126+
# Always keep at least the last message; the repair logic below pulls in its
127+
# tool_use/tool_result siblings so the kept set stays valid.
128+
if not trimmed_messages and messages:
129+
logger.warning("trim_messages returned empty; keeping the last message to avoid an empty Opey call")
130+
trimmed_messages = messages[-1:]
131+
118132
# Build an index: tool_call_id -> parent AIMessage (from full conversation)
119133
tool_call_id_to_ai_msg: Dict[str, AIMessage] = {}
120134
for msg in messages:

src/agent/components/recovery.py

Lines changed: 101 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -43,19 +43,36 @@
4343
HARD_RECAP_CHARS = 1000
4444

4545

46+
_OVERFLOW_PHRASES = (
47+
"prompt is too long", # Anthropic
48+
"context_length_exceeded", # OpenAI
49+
"maximum context length", # OpenAI (alt phrasing)
50+
"context window", # Generic / Bedrock
51+
"too many tokens", # Generic fallback
52+
)
53+
54+
4655
def _is_context_overflow(exc: BaseException) -> bool:
47-
"""True if `exc` looks like an LLM context-window-exceeded error.
56+
"""True if `exc` (or anything it wraps) looks like a context-window error.
4857
49-
Provider-agnostic by string-matching the canonical error phrasings.
58+
Walks the cause/context chain so we catch wrapped errors raised from
59+
inside LangGraph / LangChain / Anthropic SDK layers.
5060
"""
51-
msg = str(exc).lower()
52-
return (
53-
"prompt is too long" in msg # Anthropic
54-
or "context_length_exceeded" in msg # OpenAI
55-
or "maximum context length" in msg # OpenAI (alt phrasing)
56-
or "context window" in msg # Generic / Bedrock
57-
or "too many tokens" in msg # Generic fallback
58-
)
61+
seen: set[int] = set()
62+
current: BaseException | None = exc
63+
while current is not None and id(current) not in seen:
64+
seen.add(id(current))
65+
msg = str(current).lower()
66+
if any(phrase in msg for phrase in _OVERFLOW_PHRASES):
67+
return True
68+
# Anthropic SDK BadRequestError exposes the API body in .body or .response
69+
body = getattr(current, "body", None)
70+
if isinstance(body, dict):
71+
body_text = str(body).lower()
72+
if any(phrase in body_text for phrase in _OVERFLOW_PHRASES):
73+
return True
74+
current = current.__cause__ or current.__context__
75+
return False
5976

6077

6178
def _content_length(content: Any) -> int:
@@ -228,23 +245,88 @@ def drop_largest_tool_message(
228245
# ---------------------------------------------------------------------------
229246

230247

231-
GRACEFUL_DEGRADE_TEXT = (
232-
"I had to shorten this conversation to keep it within the model's "
233-
"context window. Some earlier tool responses were dropped or "
234-
"summarized. If your last question depended on data I just dropped, "
235-
"please rephrase it more specifically — for example, ask for a single "
236-
"record by id rather than a list, or narrow the time range."
237-
)
248+
def _content_to_text(content: Any) -> str:
249+
"""Best-effort flatten of message content to a plain string for inspection."""
250+
if isinstance(content, str):
251+
return content
252+
if isinstance(content, list):
253+
parts: List[str] = []
254+
for item in content:
255+
if isinstance(item, dict):
256+
parts.append(item.get("text", "") or str(item))
257+
else:
258+
parts.append(str(item))
259+
return "".join(parts)
260+
return str(content)
238261

239262

240-
def graceful_failure_message() -> AIMessage:
263+
def _summarize_last_tool_result(messages: List[BaseMessage]) -> str | None:
264+
"""Inspect the last ToolMessage and produce a one-line human summary.
265+
266+
Goal: tell the user *what came back* from the bank, so a failed LLM call
267+
doesn't look like "Opey did nothing". Best-effort and safe — returns
268+
None if we can't extract anything useful.
269+
"""
270+
last_tool: ToolMessage | None = None
271+
for msg in reversed(messages):
272+
if isinstance(msg, ToolMessage):
273+
last_tool = msg
274+
break
275+
if last_tool is None:
276+
return None
277+
278+
text = _content_to_text(last_tool.content)
279+
if not text:
280+
return None
281+
282+
# Heuristic: parse JSON, look for an obvious list of records.
283+
try:
284+
import json
285+
parsed = json.loads(text)
286+
except (ValueError, TypeError):
287+
return f"the last tool call returned {len(text):,} characters of data"
288+
289+
response = parsed.get("response", parsed) if isinstance(parsed, dict) else parsed
290+
if isinstance(response, dict):
291+
for key, value in response.items():
292+
if isinstance(value, list):
293+
return f"the OBP API returned {len(value)} `{key}` (≈ {len(text):,} chars)"
294+
return f"the OBP API returned an object with keys: {', '.join(list(response.keys())[:6])}"
295+
if isinstance(response, list):
296+
return f"the OBP API returned {len(response)} items (≈ {len(text):,} chars)"
297+
return f"the OBP API returned {len(text):,} characters of data"
298+
299+
300+
def graceful_failure_message(messages: List[BaseMessage] | None = None) -> AIMessage:
241301
"""Synthetic AIMessage used when every recovery step has failed.
242302
243303
Returning this to state lets the stream end cleanly with a useful
244304
next-action for the user instead of bubbling a 400 to the portal.
305+
306+
If `messages` is provided, inspects the most recent ToolMessage and
307+
reports what data was actually retrieved — so the user knows the bank
308+
call succeeded, only the summarization step failed.
245309
"""
246310
logger.error(
247311
"recovery step 4: all recovery steps exhausted — emitting graceful "
248312
"degrade message to caller"
249313
)
250-
return AIMessage(content=GRACEFUL_DEGRADE_TEXT)
314+
315+
retrieved_line = ""
316+
if messages:
317+
summary = _summarize_last_tool_result(messages)
318+
if summary:
319+
retrieved_line = (
320+
f"\n\n**Good news — the bank call succeeded:** "
321+
f"{summary}.\n"
322+
f"It was too large for me to summarize in one go."
323+
)
324+
325+
text = (
326+
"I couldn't fit the result of this turn into my context window."
327+
+ retrieved_line
328+
+ "\n\nPlease try a more specific question — for example, ask for "
329+
"a single record by id, narrow the time range, or filter by a "
330+
"specific bank or account."
331+
)
332+
return AIMessage(content=text)

src/agent/graph_builder.py

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from langgraph.checkpoint.base import BaseCheckpointSaver
66
from langchain_core.tools import BaseTool
77
from langchain_core.prompts import SystemMessagePromptTemplate, ChatPromptTemplate, MessagesPlaceholder
8-
from langchain_core.messages import SystemMessage
8+
from langchain_core.messages import SystemMessage, HumanMessage
99
from langchain_core.runnables import Runnable
1010
from langchain_core.language_models.chat_models import BaseChatModel
1111
from langchain_core.runnables import RunnableConfig
@@ -63,6 +63,16 @@ async def _invoke_with_recovery(
6363
return response, {}, messages
6464
except Exception as exc:
6565
if not _is_context_overflow(exc):
66+
# Log the full exception chain so we can tell whether overflow is
67+
# being wrapped in a way the predicate doesn't recognise.
68+
chain = []
69+
cur: BaseException | None = exc
70+
while cur is not None and len(chain) < 6:
71+
chain.append(f"{type(cur).__name__}: {str(cur)[:200]}")
72+
cur = cur.__cause__ or cur.__context__
73+
logger.error(
74+
f"opey: ainvoke raised non-overflow exception: {' <- '.join(chain)}"
75+
)
6676
raise
6777
logger.warning(f"opey: context overflow on first call: {exc!r}; entering recovery cascade")
6878

@@ -111,7 +121,9 @@ async def _invoke_with_recovery(
111121
logger.error(f"opey: step 3 (drop largest) did not fit: {exc!r}; graceful degrade")
112122

113123
# Step 4: graceful degrade — no LLM call, return synthetic message
114-
response = graceful_failure_message()
124+
# Pass current state messages so the message can report what data
125+
# was actually retrieved (the bank call succeeded; only summarization failed).
126+
response = graceful_failure_message(messages=state.get("messages", []))
115127
return response, state_updates, messages
116128

117129
class OpeyAgentGraphBuilder:
@@ -217,6 +229,20 @@ def _build_system_prompt(self) -> str:
217229

218230
def _get_llm(self) -> Runnable:
219231
"""Get the configured LLM"""
232+
# DIAGNOSTIC (temporary): log the size of every bound tool schema. The sum is the
233+
# fixed per-request overhead sent to the LLM on every call. Remove once resolved.
234+
try:
235+
from langchain_core.utils.function_calling import convert_to_openai_tool
236+
import json as _json
237+
_total = 0
238+
for _t in self._tools:
239+
_sz = len(_json.dumps(convert_to_openai_tool(_t)))
240+
_total += _sz
241+
logger.warning(f"[TOOL DIAG] {getattr(_t, 'name', _t)}: {_sz} chars")
242+
logger.warning(f"[TOOL DIAG] {len(self._tools)} tools, total schema {_total} chars (~{_total // 4} tokens)")
243+
except Exception as _diag_err:
244+
logger.warning(f"[TOOL DIAG] failed: {_diag_err}")
245+
220246
return get_model(
221247
self._model_name,
222248
temperature=self._temperature,
@@ -238,13 +264,44 @@ def _create_opey_node(self):
238264

239265
@cancellable(preserve_state_keys=["total_tokens"])
240266
async def run_opey(state: OpeyGraphState, config: RunnableConfig):
241-
# Check if we have a conversation summary
267+
# Ephemeral per-turn system context, prepended only for this LLM call (never
268+
# persisted to message history, so it always reflects the latest values).
269+
prepend: list[SystemMessage] = []
270+
242271
summary = state.get("conversation_summary", "")
243272
if summary:
244-
summary_system_message = f"Summary of earlier conversation: {summary}"
245-
messages = [SystemMessage(content=summary_system_message)] + state["messages"]
246-
else:
247-
messages = state["messages"]
273+
prepend.append(SystemMessage(content=f"Summary of earlier conversation: {summary}"))
274+
275+
current_bank_id = (config.get("configurable", {}) or {}).get("current_bank_id")
276+
if current_bank_id:
277+
prepend.append(SystemMessage(content=(
278+
f"The user currently has OBP bank '{current_bank_id}' selected in the UI. "
279+
f"When a request does not name a bank, use this bank_id."
280+
)))
281+
282+
messages = prepend + state["messages"]
283+
284+
# DIAGNOSTIC (temporary): locate context bloat. Logs message count, total
285+
# content size, and the 8 biggest messages. Remove once context issue is fixed.
286+
try:
287+
_sizes = sorted(
288+
((type(m).__name__, len(str(getattr(m, "content", "") or ""))) for m in messages),
289+
key=lambda x: x[1], reverse=True,
290+
)
291+
logger.warning(
292+
f"[CTX DIAG] {len(messages)} messages, "
293+
f"~{sum(s for _, s in _sizes)} content chars (~{sum(s for _, s in _sizes) // 4} tokens); "
294+
f"biggest 8: {_sizes[:8]}"
295+
)
296+
except Exception as _diag_err:
297+
logger.warning(f"[CTX DIAG] failed: {_diag_err}")
298+
299+
# Anthropic rejects a request whose messages are all system content
300+
# ("at least one message is required"). If summarisation/trimming left
301+
# nothing to respond to, add a minimal user turn so the call is valid.
302+
if not any(not isinstance(m, SystemMessage) for m in messages):
303+
logger.warning("run_opey: only system messages present — appending a continuation HumanMessage")
304+
messages = messages + [HumanMessage(content="Please continue, based on the summary above.")]
248305

249306
response, extra_updates, messages = await _invoke_with_recovery(
250307
opey_agent, messages, state, config

src/schema/schema.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,12 @@ class UserInput(BaseModel):
265265
description="Tool call approval object (with consent_jwt, approval, or batch_decisions) or False if not an approval.",
266266
default=False,
267267
)
268+
current_bank_id: str | None = Field(
269+
description="Bank the user currently has selected in the UI. Used as the default bank "
270+
"for requests that don't name one.",
271+
default=None,
272+
examples=["gh.29.uk"],
273+
)
268274

269275

270276
class StreamInput(UserInput):

src/service/streaming/events.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ class AssistantCompleteEvent(BaseStreamEvent):
5050
run_id: str = Field(description="Unique identifier for this run")
5151
content: str = Field(description="The complete response content")
5252
tool_calls: Optional[list] = Field(default=[], description="Any tool calls made by the assistant")
53+
usage: Optional[dict] = Field(
54+
default=None,
55+
description="Token usage for this LLM call (input_tokens, output_tokens, total_tokens)."
56+
)
5357

5458
def to_sse_data(self) -> str:
5559
return f"data: {self.model_dump_json()}\n\n"
@@ -323,16 +327,17 @@ def assistant_token(content: str, message_id: str) -> AssistantTokenEvent:
323327
return event
324328

325329
@staticmethod
326-
def assistant_complete(content: str, message_id: str, run_id: str, tool_calls: Optional[list] = None) -> AssistantCompleteEvent:
327-
event = AssistantCompleteEvent(content=content, message_id=message_id, run_id=run_id, tool_calls=tool_calls or [])
330+
def assistant_complete(content: str, message_id: str, run_id: str, tool_calls: Optional[list] = None, usage: Optional[dict] = None) -> AssistantCompleteEvent:
331+
event = AssistantCompleteEvent(content=content, message_id=message_id, run_id=run_id, tool_calls=tool_calls or [], usage=usage)
328332
StreamEventFactory._log_event(
329-
event,
330-
"ASSISTANT_COMPLETE",
333+
event,
334+
"ASSISTANT_COMPLETE",
331335
{
332336
"message_id": message_id,
333337
"run_id": run_id,
334338
"content_length": len(content),
335-
"tool_calls": len(tool_calls or [])
339+
"tool_calls": len(tool_calls or []),
340+
"usage": usage
336341
}
337342
)
338343
return event

src/service/streaming/processors.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,11 +124,17 @@ async def process(self, event: LangGraphStreamEvent) -> AsyncGenerator[StreamEve
124124
# This ensures each assistant response gets a fresh state
125125
self._reset_streaming_state()
126126

127+
# usage_metadata carries Anthropic's real token counts for this
128+
# call — input_tokens is the actual prompt size (tools + system +
129+
# history), the number that hits the 200k limit.
130+
usage = getattr(message, 'usage_metadata', None)
131+
127132
yield StreamEventFactory.assistant_complete(
128133
content=content,
129134
message_id=message_id,
130135
run_id=run_id,
131-
tool_calls=tool_calls
136+
tool_calls=tool_calls,
137+
usage=usage
132138
)
133139
except Exception as e:
134140
error_msg = f"Error processing assistant message completion: {str(e)}"

src/service/streaming/stream_manager.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ async def stream_response(
4040
"""
4141

4242
thread_id = config.get("configurable", {}).get("thread_id")
43+
44+
# Carry the UI-selected bank into the graph config so the agent node can inject it
45+
# as ephemeral context each turn. Kept out of message history so it never goes stale.
46+
if stream_input.current_bank_id:
47+
config.setdefault("configurable", {})["current_bank_id"] = stream_input.current_bank_id
48+
4349
logger.info("Starting stream response", extra={
4450
"event_type": "stream_start",
4551
"thread_id": thread_id,

0 commit comments

Comments
 (0)