From 97f577a6109c45a6e8921a5c4bd5bb7476d3afc8 Mon Sep 17 00:00:00 2001 From: Sandiyo Christan <55909152+sandiyochristan@users.noreply.github.com> Date: Thu, 21 May 2026 07:15:16 +0530 Subject: [PATCH 1/4] feat: add HTTP request smuggling skill (#405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add HTTP request smuggling skill Add a new vulnerability skill covering HTTP request smuggling (HRS) across CL.TE, TE.CL, H2.CL, and H2.TE desync variants. HRS is absent from the existing skill set despite being a distinct, high-impact vulnerability class frequently present in any architecture using a reverse proxy or CDN in front of an application server. Coverage: - CL.TE: front-end uses Content-Length, back-end uses Transfer-Encoding - TE.CL: front-end uses Transfer-Encoding, back-end uses Content-Length - H2.CL: HTTP/2 front-end downgrades to HTTP/1.1 with injected Content-Length - H2.TE: Transfer-Encoding header injection through HTTP/2 desync - Transfer-Encoding obfuscation techniques (tab, space, duplicate, xchunked) - Front-end security control bypass via smuggled prefix - Cross-user request capture for session token theft - Response queue poisoning and WebSocket handshake hijacking - Timing-based and differential response detection methodology - HTTP/2 specific probing techniques Includes raw HTTP examples for each variant, step-by-step testing methodology, exploitation PoCs, false-positive conditions, and infrastructure topology guidance. * fix: correct TE.CL probe, pseudo-header terminology, PoC Content-Length values, \x20 representation Four reviewer findings addressed: P1 — TE.CL timing-probe description inverted: previous text said 'Content-Length set to fewer bytes than the chunk content' which describes socket-poisoning behavior (differential response), not a timeout. Corrected to: send a complete chunked body with CL set to MORE bytes than provided so the back-end waits for data that never arrives. Also corrected Testing Methodology step 3 to match. P2 — pseudo-header terminology: 'content-length' is a regular HTTP/2 header, not a pseudo-header (pseudo-headers are exclusively :method, :path, :authority, :scheme). Fixed the H2.CL explanation (line 75), HTTP/2-specific detection bullet, and Pro Tip #4 which referred to ':content-length pseudo-header'. P2 — PoC Content-Length values: outer Content-Length in the bypass PoC corrected from 116 to 100 (actual byte count of the body shown); capture PoC corrected from 129 to 120. P2 — \x20 representation: replaced the \x20 escape sequence in the code block (which renders as a literal four-character string, not a space byte) with an explanatory comment and actual whitespace characters so the intent is unambiguous. * Update strix/skills/vulnerabilities/http_request_smuggling.md Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .../vulnerabilities/http_request_smuggling.md | 255 ++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 strix/skills/vulnerabilities/http_request_smuggling.md diff --git a/strix/skills/vulnerabilities/http_request_smuggling.md b/strix/skills/vulnerabilities/http_request_smuggling.md new file mode 100644 index 000000000..5db2bd109 --- /dev/null +++ b/strix/skills/vulnerabilities/http_request_smuggling.md @@ -0,0 +1,255 @@ +--- +name: http-request-smuggling +description: HTTP request smuggling testing covering CL.TE, TE.CL, H2.CL, H2.TE, and HTTP/2 desync techniques with practical detection and exploitation methodology +--- + +# HTTP Request Smuggling + +HTTP request smuggling (HRS) exploits disagreements between a front-end proxy and a back-end server about where one HTTP request ends and the next begins. When the two systems parse `Content-Length` and `Transfer-Encoding` headers differently, an attacker can prefix a hidden request to the back-end's socket, which is then prepended to the next legitimate user's request. The impact ranges from bypassing front-end security controls to full cross-user session hijacking. + +## Attack Surface + +**Infrastructure Topologies** +- CDN or load balancer in front of origin server (Cloudflare, Nginx, HAProxy, AWS ALB) +- Reverse proxy chains (Nginx → Gunicorn, HAProxy → Node.js, Varnish → Apache) +- API gateways forwarding to microservices +- HTTP/2 front-end to HTTP/1.1 back-end translation (H2.CL / H2.TE) +- Tunneling servers or WAFs that terminate and re-forward requests + +**HTTP Versions in Play** +- HTTP/1.1: CL.TE and TE.CL classic smuggling +- HTTP/2: H2.CL (downgrade injects Content-Length) and H2.TE (injects Transfer-Encoding) +- HTTP/3: emerging QUIC-based desync (less common, research-stage) + +**Parser Differentials** +- Treatment of duplicate `Content-Length` headers +- Handling of `Transfer-Encoding: chunked` when `Content-Length` is also present +- Chunk size obfuscation via whitespace, tab, case, or invalid extensions + +## High-Value Targets + +- Front-end security controls (authentication bypass via desync) +- Endpoints shared by many users (high-traffic APIs, chat, feeds) +- Request capture endpoints (search, logging, analytics) +- Session-sensitive endpoints (auth callbacks, account settings) +- Internal admin interfaces proxied through the same connection pool + +## Core Concepts + +### CL.TE — Front-end uses Content-Length, Back-end uses Transfer-Encoding + +Front-end reads `Content-Length: X` bytes and forwards. Back-end reads until the `0\r\n\r\n` chunk terminator. Attacker appends a hidden request after the `0` terminator that the front-end considers part of the same body but the back-end treats as a new request. + +```http +POST / HTTP/1.1 +Host: target.com +Content-Length: 6 +Transfer-Encoding: chunked + +0 + +G +``` +The `G` is left in the back-end's socket buffer and prepended to the next request. + +### TE.CL — Front-end uses Transfer-Encoding, Back-end uses Content-Length + +Front-end reads chunked body to completion. Back-end reads only `Content-Length` bytes, leaving the remainder on the socket. + +```http +POST / HTTP/1.1 +Host: target.com +Content-Type: application/x-www-form-urlencoded +Content-Length: 3 +Transfer-Encoding: chunked + +8 +SMUGGLED +0 + + +``` + +### H2.CL — HTTP/2 Front-end Downgrades to HTTP/1.1, Injects Content-Length + +HTTP/2 has no `Content-Length` vs `TE` ambiguity in its own framing. But when the front-end downgrades to HTTP/1.1 for the back-end, an attacker can inject a `content-length` header in the HTTP/2 request that conflicts with the actual body length. Note: `content-length` is a regular HTTP/2 header — pseudo-headers are exclusively `:method`, `:path`, `:authority`, and `:scheme`: +``` +:method POST +:path / +:authority target.com +content-type application/x-www-form-urlencoded +content-length: 0 + +SMUGGLED_PREFIX +``` + +### H2.TE — HTTP/2 Injects Transfer-Encoding Header + +Inject `transfer-encoding: chunked` in HTTP/2 headers (which the HTTP/2 spec forbids, but some front-ends pass through). Back-end receives both headers, may prefer TE over CL. + +``` +:method POST +:path / +transfer-encoding: chunked + +0 + +SMUGGLED +``` + +## Key Vulnerabilities + +### Front-End Security Control Bypass + +A front-end proxy enforces authentication or IP restriction by checking request headers and blocking or allowing based on rules. If a smuggled prefix bypasses the front-end (because it's buried in a prior request's body from the front-end's view), the back-end processes it without the security check. + +**PoC structure (CL.TE):** +```http +POST /not-restricted HTTP/1.1 +Host: target.com +Content-Length: 100 +Transfer-Encoding: chunked + +0 + +GET /admin HTTP/1.1 +Host: target.com +X-Forwarded-Host: target.com +Content-Length: 10 + +x=1 +``` +The `GET /admin` is seen by the back-end as a new, legitimate request originating from the trusted proxy IP. + +### Cross-User Request Capture + +Poison the back-end socket with a partial request prefix that captures the next victim user's request (including their cookies, tokens, request body) into the response of a controlled endpoint (search, comment submission). + +**PoC structure (CL.TE capture):** +```http +POST /search HTTP/1.1 +Host: target.com +Content-Length: 120 +Transfer-Encoding: chunked + +0 + +POST /search HTTP/1.1 +Host: target.com +Content-Type: application/x-www-form-urlencoded +Content-Length: 100 + +q= +``` +`Content-Length: 100` in the smuggled prefix is longer than the actual smuggled body, so the back-end waits for 100 bytes — which it sources from the *next* user's request. The `/search` endpoint reflects the query, capturing headers and body of the subsequent request. + +### Response Queue Poisoning + +On pipelined connections, cause a misaligned response to be delivered to the wrong user (HTTP/1.1 response queue poisoning). Used to deliver attacker-controlled content or steal another user's response. + +### Request Reflection / Cache Poisoning Chain + +Smuggle a prefix that hits a cacheable endpoint with an injected `Host` header. If the cache stores the response keyed only on URL, the poisoned response is served to all users requesting that URL. + +### WebSocket Handshake Hijacking + +If the proxy performs WebSocket upgrade, a smuggled `Upgrade` request can hijack an existing WebSocket connection from a subsequent user. + +## Detection Techniques + +### Timing-Based Detection + +**CL.TE:** Send a request where `Content-Length` is complete but `Transfer-Encoding` body is missing the `0\r\n\r\n` terminator. A CL.TE-vulnerable back-end waits for the terminator, causing a timeout. + +```http +POST / HTTP/1.1 +Host: target.com +Transfer-Encoding: chunked +Content-Length: 6 + +3 +abc +X +``` +If response is delayed 10–30 seconds, CL.TE desync likely. + +**TE.CL:** Send a request with a complete chunked body (including the `0\r\n\r\n` terminator so the front-end is satisfied) but with `Content-Length` set to **more** bytes than the body actually provides. The back-end, using Content-Length, waits for the remaining bytes that never arrive — producing a 10–30 second timeout. Setting Content-Length *less* than the body causes socket poisoning (differential-response detection), not a timeout. + +### Differential Response Detection + +Send two requests in sequence. If the second request receives an unexpected response (error, redirect, wrong content), the first may have poisoned the socket. Use a unique string in the smuggled prefix to confirm. + +### Content-Length + Transfer-Encoding Combination + +```http +Transfer-Encoding: xchunked # non-standard value, some FE ignore, BE accept +Transfer-Encoding: chunked # leading space before value (0x20 byte after colon+space) +Transfer-Encoding: chunked # tab character before value +Transfer-Encoding: x +Transfer-Encoding: chunked # duplicate TE headers, BE uses last +``` + +## Transfer-Encoding Obfuscation + +To force TE disagreement: +``` +Transfer-Encoding: xchunked +Transfer-Encoding : chunked # space before colon +X: XTransfer-Encoding: chunked # header injection — inject actual CRLF bytes at , not the literal string \r\n +Transfer-Encoding: chunkedTransfer-Encoding: x # TE twice — inject actual CRLF bytes at +``` + +## HTTP/2-Specific Detection + +- Send HTTP/2 requests with an injected `content-length` regular header that differs from the actual body length +- Inject `transfer-encoding: chunked` in HTTP/2 headers (spec-forbidden but sometimes passed through) +- Use HTTP/2 header injection: inject newlines in header values if the front-end passes them to HTTP/1.1 back-end unescaped +- Observe whether the HTTP/2 connection ID corresponds to a persistent HTTP/1.1 connection to the back-end (connection reuse amplifies impact) + +## Testing Methodology + +1. **Map the proxy chain** — identify front-end (CDN, load balancer, WAF) and back-end (app server) +2. **Probe CL.TE** — send a timing probe with mismatched chunked terminator; observe delay +3. **Probe TE.CL** — send a timing probe with complete chunked body but Content-Length larger than the actual body; observe back-end timeout +4. **Obfuscate TE header** — try each obfuscation variant (tab, extra space, duplicate, non-standard value) +5. **Confirm with differential response** — send two rapid identical requests; if second gets an unexpected response, socket is poisoned +6. **Attempt bypass exploit** — craft a smuggled `GET /admin` or restricted endpoint and observe if back-end accepts it +7. **Attempt capture** — poison with a partial POST pointing to a reflective endpoint; wait for a follow-up request to fill the buffer +8. **Test H2.CL/H2.TE** — repeat the same probes over HTTP/2 connections if the target supports HTTP/2 + +## Validation + +1. Show a timing differential of 10+ seconds on the CL.TE or TE.CL probe and explain the mechanism +2. Demonstrate a bypass: smuggle a request to `/admin` and receive a 200 response where a direct request returns 403 +3. For capture: show a subsequent user's `Cookie` or `Authorization` header appearing in the response of a controlled endpoint +4. Confirm with a unique marker string in the smuggled prefix to rule out timing noise +5. Provide the exact raw bytes of the smuggled request + +## False Positives + +- General network latency or server-side processing delays unrelated to smuggling +- Server consistently close connection after first request (no connection reuse, no socket sharing) +- HTTP/2 with full end-to-end HTTP/2 to back-end (no HTTP/1.1 downgrade, no desync surface) +- WAF or proxy that normalizes TE/CL headers before forwarding (removes the ambiguity) + +## Impact + +- Authentication and authorization bypass by smuggling requests past front-end access controls +- Cross-user session hijacking by capturing requests containing session tokens +- Cache poisoning affecting all users of a cached resource +- Internal service access bypassing IP-based restrictions enforced at the front-end +- XSS delivery via response queue poisoning in shared connection contexts + +## Pro Tips + +1. Use Burp Suite's HTTP Request Smuggler extension as a rapid scanner, but always confirm manually — false positives are common +2. TE obfuscation is the most reliable path; `Transfer-Encoding: xchunked` works on many Apache/IIS back-ends +3. Keep smuggled prefixes short during detection; use the minimal body to confirm desync before attempting capture attacks +4. H2.CL is the most impactful modern variant — many CDNs translate HTTP/2 to HTTP/1.1 and derive `Content-Length` from the `content-length` regular header sent in the HTTP/2 request (not a pseudo-header — inject it as a normal header field) +5. In capture attacks, set `Content-Length` in the smuggled prefix larger than your partial body by 50–100 bytes to catch a full auth header from the next user +6. Test during low-traffic periods first to avoid affecting real users; always get explicit authorization for capture attempts +7. If timing probes are inconsistent, pipeline two requests over the same connection and look for unexpected response swapping + +## Summary + +HTTP request smuggling is eliminated by enforcing consistent TE/CL interpretation at every hop in the proxy chain, preferring end-to-end HTTP/2, and having back-end servers reject or normalize ambiguous requests. At the proxy level, never forward TE headers that were not present in the original request, and treat conflicting CL + TE as a hard error. From 3508da4beb715c61b22910d9be3575a961cea639 Mon Sep 17 00:00:00 2001 From: Matthew Flanagan <188046+mattimustang@users.noreply.github.com> Date: Thu, 21 May 2026 17:28:40 +1000 Subject: [PATCH 2/4] perf(tui): fix O(N) scans and redundant 60fps renders during waiting state Three hot methods were scanning the entire tool_executions dict on every tick instead of using the per-agent index already maintained by the Tracer. This made CPU cost proportional to total accumulated tool executions, which is worst exactly when agents finish and enter waiting/stopped state. - _agent_has_real_activity: was O(all_tool_executions) at 60ms; now uses agents[agent_id]["tool_executions"] index - _agent_vulnerability_count: same full scan per agent per 350ms tick; now scoped to the agent's own executions - _gather_agent_events: same full scan on every 350ms tick, even before the cache check that would discard the result; now scoped per agent Also stop calling _update_agent_status_display from _animate_dots when the selected agent is in "waiting" state. The waiting display is static text ("Send message to resume") that never changes until the user acts, but the 60ms timer was pushing Textual widget updates for it at 16fps anyway. The 350ms _update_ui_from_tracer call is sufficient to render the waiting state. --- strix/interface/tui.py | 51 ++++++++++++++++++++++-------------------- 1 file changed, 27 insertions(+), 24 deletions(-) diff --git a/strix/interface/tui.py b/strix/interface/tui.py index 0cfd75411..7b9932215 100644 --- a/strix/interface/tui.py +++ b/strix/interface/tui.py @@ -1411,6 +1411,7 @@ def _animate_dots(self) -> None: status = agent_data.get("status", "running") if status in ["running", "waiting"]: has_active_agents = True + if status == "running": num_colors = len(self._sweep_colors) offset = num_colors - 1 max_pos = (self._sweep_num_squares - 1) + offset @@ -1432,26 +1433,25 @@ def _animate_dots(self) -> None: def _agent_has_real_activity(self, agent_id: str) -> bool: initial_tools = {"scan_start_info", "subagent_start_info"} - for _exec_id, tool_data in list(self.tracer.tool_executions.items()): - if tool_data.get("agent_id") == agent_id: - tool_name = tool_data.get("tool_name", "") - if tool_name not in initial_tools: - return True + agent_data = self.tracer.agents.get(agent_id, {}) + for exec_id in agent_data.get("tool_executions", []): + tool_data = self.tracer.tool_executions.get(exec_id) + if tool_data and tool_data.get("tool_name", "") not in initial_tools: + return True streaming = self.tracer.get_streaming_content(agent_id) return bool(streaming and streaming.strip()) def _agent_vulnerability_count(self, agent_id: str) -> int: count = 0 - for _exec_id, tool_data in list(self.tracer.tool_executions.items()): - if tool_data.get("agent_id") == agent_id: - tool_name = tool_data.get("tool_name", "") - if tool_name == "create_vulnerability_report": - status = tool_data.get("status", "") - if status == "completed": - result = tool_data.get("result", {}) - if isinstance(result, dict) and result.get("success"): - count += 1 + agent_data = self.tracer.agents.get(agent_id, {}) + for exec_id in agent_data.get("tool_executions", []): + tool_data = self.tracer.tool_executions.get(exec_id) + if tool_data and tool_data.get("tool_name") == "create_vulnerability_report": + if tool_data.get("status") == "completed": + result = tool_data.get("result", {}) + if isinstance(result, dict) and result.get("success"): + count += 1 return count def _gather_agent_events(self, agent_id: str) -> list[dict[str, Any]]: @@ -1466,16 +1466,19 @@ def _gather_agent_events(self, agent_id: str) -> list[dict[str, Any]]: if msg.get("agent_id") == agent_id ] - tool_events = [ - { - "type": "tool", - "timestamp": tool_data["timestamp"], - "id": f"tool_{exec_id}", - "data": tool_data, - } - for exec_id, tool_data in list(self.tracer.tool_executions.items()) - if tool_data.get("agent_id") == agent_id - ] + agent_data = self.tracer.agents.get(agent_id, {}) + tool_events = [] + for exec_id in agent_data.get("tool_executions", []): + tool_data = self.tracer.tool_executions.get(exec_id) + if tool_data: + tool_events.append( + { + "type": "tool", + "timestamp": tool_data["timestamp"], + "id": f"tool_{exec_id}", + "data": tool_data, + } + ) events = chat_events + tool_events events.sort(key=lambda e: (e["timestamp"], e["id"])) From 4b17b5769655451b7721c1912e2e58f0679ddf70 Mon Sep 17 00:00:00 2001 From: Matthew Flanagan <188046+mattimustang@users.noreply.github.com> Date: Thu, 21 May 2026 17:35:11 +1000 Subject: [PATCH 3/4] perf(tui): cache event renders and eliminate redundant work during running state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three more performance issues in the running state hot path: Per-event render cache in _get_rendered_events_content: every 350ms tick during active streaming caused a full re-render of all events in the conversation — every chat message through AgentMessageRenderer (including Pygments syntax highlighting for code blocks) and every tool event. Chat messages and completed/failed tool events are now cached by (event_id, status) and only re-rendered when their status changes. Running tool events are re-rendered each tick as their content may still update. Skip duplicate _update_agent_status_display in _update_ui_from_tracer when the dot animation timer is active: _animate_dots (60ms) already calls it for "running" agents, so the unconditional call from _update_ui_from_tracer (350ms) was redundant, doubling the widget update rate during active scans. Fix _get_agent_name_for_vulnerability to use per-agent tool execution index instead of scanning all tool_executions, consistent with the other O(N) scan fixes from the previous commit. --- strix/interface/tui.py | 36 ++++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/strix/interface/tui.py b/strix/interface/tui.py index 7b9932215..086701bbe 100644 --- a/strix/interface/tui.py +++ b/strix/interface/tui.py @@ -715,6 +715,7 @@ def __init__(self, args: argparse.Namespace): self._streaming_render_cache: dict[str, tuple[int, Any]] = {} self._last_streaming_len: dict[str, int] = {} + self._event_render_cache: dict[str, Any] = {} self._scan_thread: threading.Thread | None = None self._scan_stop_event = threading.Event() @@ -925,7 +926,8 @@ def _update_ui_from_tracer(self) -> None: self._update_chat_view() - self._update_agent_status_display() + if self._dot_animation_timer is None: + self._update_agent_status_display() self._update_stats_display() @@ -1091,11 +1093,24 @@ def _get_rendered_events_content(self, events: list[dict[str, Any]]) -> Any: for event in events: content: Any = None + event_id = event["id"] if event["type"] == "chat": - content = self._render_chat_content(event["data"]) + if event_id in self._event_render_cache: + content = self._event_render_cache[event_id] + else: + content = self._render_chat_content(event["data"]) + if content is not None: + self._event_render_cache[event_id] = content elif event["type"] == "tool": - content = self._render_tool_content_simple(event["data"]) + status = event["data"].get("status", "") + cache_key = f"{event_id}_{status}" + if cache_key in self._event_render_cache: + content = self._event_render_cache[cache_key] + else: + content = self._render_tool_content_simple(event["data"]) + if content is not None and status in ("completed", "failed", "error"): + self._event_render_cache[cache_key] = content if content: if renderables: @@ -1344,13 +1359,13 @@ def _update_vulnerabilities_panel(self) -> None: def _get_agent_name_for_vulnerability(self, report_id: str) -> str | None: """Find the agent name that created a vulnerability report.""" - for _exec_id, tool_data in list(self.tracer.tool_executions.items()): - if tool_data.get("tool_name") == "create_vulnerability_report": - result = tool_data.get("result", {}) - if isinstance(result, dict) and result.get("report_id") == report_id: - agent_id = tool_data.get("agent_id") - if agent_id and agent_id in self.tracer.agents: - name: str = self.tracer.agents[agent_id].get("name", "Unknown Agent") + for agent_id, agent_data in self.tracer.agents.items(): + for exec_id in agent_data.get("tool_executions", []): + tool_data = self.tracer.tool_executions.get(exec_id) + if tool_data and tool_data.get("tool_name") == "create_vulnerability_report": + result = tool_data.get("result", {}) + if isinstance(result, dict) and result.get("report_id") == report_id: + name: str = agent_data.get("name", "Unknown Agent") return name return None @@ -1494,6 +1509,7 @@ def watch_selected_agent_id(self, _agent_id: str | None) -> None: self._displayed_events.clear() self._streaming_render_cache.clear() self._last_streaming_len.clear() + self._event_render_cache.clear() self.call_later(self._update_chat_view) self._update_agent_status_display() From d0cbaec173cf8617b821a7c5713a6ca29f5d7b12 Mon Sep 17 00:00:00 2001 From: Matthew Flanagan <188046+mattimustang@users.noreply.github.com> Date: Fri, 22 May 2026 12:44:52 +1000 Subject: [PATCH 4/4] perf(tui): index chat_messages by agent to eliminate O(N) scan per frame --- strix/interface/tui.py | 3 +-- strix/telemetry/tracer.py | 2 ++ 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/strix/interface/tui.py b/strix/interface/tui.py index 086701bbe..a8a71cbdc 100644 --- a/strix/interface/tui.py +++ b/strix/interface/tui.py @@ -1477,8 +1477,7 @@ def _gather_agent_events(self, agent_id: str) -> list[dict[str, Any]]: "id": f"chat_{msg['message_id']}", "data": msg, } - for msg in self.tracer.chat_messages - if msg.get("agent_id") == agent_id + for msg in self.tracer.chat_messages_by_agent.get(agent_id, []) ] agent_data = self.tracer.agents.get(agent_id, {}) diff --git a/strix/telemetry/tracer.py b/strix/telemetry/tracer.py index 3f3ca6c69..eb4c0d7ac 100644 --- a/strix/telemetry/tracer.py +++ b/strix/telemetry/tracer.py @@ -57,6 +57,7 @@ def __init__(self, run_name: str | None = None): self.agents: dict[str, dict[str, Any]] = {} self.tool_executions: dict[int, dict[str, Any]] = {} self.chat_messages: list[dict[str, Any]] = [] + self.chat_messages_by_agent: dict[str, list[dict[str, Any]]] = {} self.streaming_content: dict[str, str] = {} self.interrupted_content: dict[str, str] = {} @@ -474,6 +475,7 @@ def log_chat_message( } self.chat_messages.append(message_data) + self.chat_messages_by_agent.setdefault(agent_id, []).append(message_data) self._emit_event( "chat.message", actor={"agent_id": agent_id, "role": role},