diff --git a/src/agent_loop.py b/src/agent_loop.py index a7d49d0f4..c17683883 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -11,7 +11,6 @@ import collections import json import re import time -import re import logging from typing import AsyncGenerator, List, Dict, Optional, Set from urllib.parse import urlparse @@ -39,14 +38,151 @@ from src.agent_tools import ( logger = logging.getLogger(__name__) -_SENSITIVE_BEARER_RE = re.compile( - r"(?i)\b(authorization\s*[:=]\s*bearer\s+)[A-Za-z0-9._~+/=-]+" +# Redaction patterns for common secret-bearing shapes. Explicit and tested +# (see tests/test_loop_guard_signals.py) rather than one clever broad regex — +# safety first, but we try not to mangle harmless prose. Applied in order. +_REDACTED = "[redacted]" + +# Cookie: ... / Set-Cookie: ... — redact the rest of the line (cookies hold spaces). +_SENSITIVE_COOKIE_RE = re.compile( + r"(?i)\b((?:set-)?cookie\s*[:=]\s*)[^\r\n]+" ) -_SENSITIVE_VALUE_RE = re.compile( - r"(?i)\b(password|passwd|pwd|token|api[_-]?key|secret)\b" - r"(\s*[:=]\s*)" - r"([^\s,;]+)" +# URL credentials, e.g. postgres://user:pass@host/db. The password half allows +# inner colons (postgres://user:pa:ss@host/db) but still stops at / and @. +_SENSITIVE_URL_CRED_RE = re.compile( + r"(?i)\b([a-z][a-z0-9+.\-]*://)[^\s:/@]+:[^\s/@]+@" ) +# Prefix-only discovery regexes. Each matches the key and its separator (the part +# we KEEP); the value that follows is found by a linear scanner rather than by a +# regex, so there is no backtracking-prone quantifier over uncontrolled input. +# +# Authorization: Bearer / Authorization: Basic "two word secret" +_AUTH_PREFIX_RE = re.compile( + r"(?i)authorization\s*[:=]\s*(?:bearer|basic)\s+" +) +# Provider-prefixed env names, e.g. OPENAI_API_KEY=..., AWS_SECRET_ACCESS_KEY=..., +# GITHUB_TOKEN=... — require a sensitive suffix preceded by `_` so benign names +# that merely end in KEY (MONKEY, TURKEY) are left alone. +_ENV_PREFIX_RE = re.compile( + r"(?:export\s+)?\b[A-Z][A-Z0-9_]*" + r"_(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|PWD|CREDENTIALS?)\s*=\s*" +) +# Generic sensitive key, e.g. password=..., api_key: ..., client_secret=... +_KEY_PREFIX_RE = re.compile( + r"(?i)\b(?:password|passwd|pwd|token|api[_-]?key|client_secret|secret)\b\s*[:=]\s*" +) +# Obvious provider-shaped bare tokens (no surrounding key needed). +_SENSITIVE_BARE_TOKEN_RE = re.compile( + r"\b(" + r"sk-[A-Za-z0-9_\-]{16,}" # OpenAI / Anthropic style + r"|gh[pousr]_[A-Za-z0-9]{20,}" # GitHub PAT + r"|xox[baprs]-[A-Za-z0-9\-]{10,}" # Slack + r"|AKIA[0-9A-Z]{16}" # AWS access key id + r"|hf_[A-Za-z0-9]{16,}" # Hugging Face token + r"|AIza[0-9A-Za-z_\-]{20,}" # Google API key + r")\b" +) + + +def _consume_secret_value_end(text: str, start: int) -> int: + """Return the exclusive end index of the secret value beginning at ``start``. + + If the value is quoted, scan to the matching unescaped quote (backslash + escapes are skipped two chars at a time). Otherwise scan to the first + whitespace, comma, or semicolon. The scan is linear in the length of the + input, so it cannot exhibit catastrophic backtracking. + """ + n = len(text) + if start >= n: + return start + quote = text[start] + if quote in ("'", '"'): + i = start + 1 + while i < n: + ch = text[i] + if ch == "\\": + i += 2 + continue + if ch == quote: + return i + 1 + i += 1 + return n # unterminated quote: redact to the end + i = start + while i < n and not text[i].isspace() and text[i] not in (",", ";"): + i += 1 + return i + + +def _redact_after_prefix(text: str, prefix_re: "re.Pattern") -> str: + """Redact the value following each ``prefix_re`` match using a linear scan.""" + result = [] + pos = 0 + n = len(text) + while pos < n: + match = prefix_re.search(text, pos) + if match is None: + result.append(text[pos:]) + break + result.append(text[pos:match.end()]) + value_end = _consume_secret_value_end(text, match.end()) + if value_end > match.end(): + result.append(_REDACTED) + pos = value_end + else: + # Empty value: nothing to redact; step past the prefix and continue. + pos = match.end() + if pos < n: + result.append(text[pos]) + pos += 1 + return "".join(result) + + +def _redact_private_keys(text: str) -> str: + """Replace PEM private-key blocks with a placeholder via linear scanning. + + Finds ``-----BEGIN `` markers, verifies the header names a PRIVATE KEY, + locates the matching ``-----END `` marker, and collapses the whole block. + No regex is used, so the (multi-line, uncontrolled) body cannot trigger + polynomial matching. + """ + begin_marker = "-----BEGIN " + end_marker = "-----END " + dash = "-----" + max_header = 64 # generous bound on "[TYPE ]PRIVATE KEY" + result = [] + pos = 0 + while True: + begin = text.find(begin_marker, pos) + if begin == -1: + result.append(text[pos:]) + return "".join(result) + header_start = begin + len(begin_marker) + header_close = text.find(dash, header_start) + if ( + header_close == -1 + or header_close - header_start > max_header + or not text[header_start:header_close].endswith("PRIVATE KEY") + ): + result.append(text[pos:header_start]) + pos = header_start + continue + end = text.find(end_marker, header_close) + if end == -1: + result.append(text[pos:]) + return "".join(result) + end_header_start = end + len(end_marker) + end_close = text.find(dash, end_header_start) + if ( + end_close == -1 + or end_close - end_header_start > max_header + or not text[end_header_start:end_close].endswith("PRIVATE KEY") + ): + result.append(text[pos:header_start]) + pos = header_start + continue + result.append(text[pos:begin]) + result.append("[redacted private key]") + pos = end_close + len(dash) def _redact_sensitive_text(value: object) -> str: @@ -55,10 +191,13 @@ def _redact_sensitive_text(value: object) -> str: return "" text = str(value) - text = _SENSITIVE_BEARER_RE.sub(r"\1[redacted]", text) - return _SENSITIVE_VALUE_RE.sub(r"\1\2[redacted]", text) - - + text = _redact_private_keys(text) + text = _redact_after_prefix(text, _AUTH_PREFIX_RE) + text = _SENSITIVE_COOKIE_RE.sub(r"\1" + _REDACTED, text) + text = _SENSITIVE_URL_CRED_RE.sub(r"\1" + _REDACTED + "@", text) + text = _redact_after_prefix(text, _ENV_PREFIX_RE) + text = _redact_after_prefix(text, _KEY_PREFIX_RE) + return _SENSITIVE_BARE_TOKEN_RE.sub(_REDACTED, text) def _load_mcp_disabled_map() -> Dict[str, set]: @@ -2670,7 +2809,12 @@ async def stream_agent_loop( if _looks_like_promise: _intent_nudge_count += 1 _matched_phrase = _intent_match.group(0).strip() - logger.info(f"[agent] intent-without-action nudge #{_intent_nudge_count} on round {round_num}: {_matched_phrase!r}") + # Don't log the matched phrase — it's raw model text that may + # carry credentials. Structural metadata only. + logger.info( + "[agent] intent-without-action nudge #%d on round %d", + _intent_nudge_count, round_num, + ) messages.append({ "role": "system", "content": ( @@ -2691,14 +2835,17 @@ async def stream_agent_loop( # look like a clean completion. if _promise_shape and _intent_nudge_count >= _MAX_INTENT_NUDGES: _matched_phrase = _intent_match.group(0).strip() + _matched_phrase_safe = _redact_sensitive_text(_matched_phrase) _in_message = ( f"Intent-nudge cap reached on round {round_num}: the model " - f"announced an action ({_matched_phrase!r}) without a tool call " + f"announced an action ({_matched_phrase_safe!r}) without a tool call " f"after {_intent_nudge_count} nudge(s); ending the turn." ) + # Do not log the matched phrase, even redacted. It is raw model + # text and may contain credentials; keep logs structural only. logger.warning( - "[agent] intent-nudge cap exhausted on round %d (%d/%d): %r", - round_num, _intent_nudge_count, _MAX_INTENT_NUDGES, _matched_phrase, + "[agent] intent-nudge cap exhausted on round %d (%d/%d)", + round_num, _intent_nudge_count, _MAX_INTENT_NUDGES, ) yield f'data: {json.dumps({"type": "intent_nudge_exhausted", "round": round_num, "nudges": _intent_nudge_count, "max_nudges": _MAX_INTENT_NUDGES, "message": _in_message})}\n\n' break # no tools — done @@ -2740,10 +2887,12 @@ async def stream_agent_loop( f"Loop-breaker stopped the agent on round {round_num}: {reason}. " "Forced one tool-free round to converge on an answer or state what's blocked." ) + # Log structural metadata only — `_sig` is raw tool-call content + # that may carry credentials. logger.warning( "[agent] loop-breaker tripped on round %d (%s); " - "stuck_rounds=%d/%d runaway=%r sig=%r", - round_num, reason, _stuck_rounds, _MAX_STUCK_ROUNDS, _runaway, _sig[:80], + "stuck_rounds=%d/%d runaway=%r", + round_num, reason, _stuck_rounds, _MAX_STUCK_ROUNDS, _runaway, ) # Surface the stop cause to the stream so the user (and journalctl) # can tell a guard fired, not a clean completion. @@ -2826,6 +2975,10 @@ async def stream_agent_loop( cmd_display = block.content.split("\n")[0].strip()[:80] else: cmd_display = block.content.strip() + # The display string is streamed (tool_start/tool_output) and persisted; + # redact any secrets in it. block.content itself is left untouched so + # tool execution still sees the real command. + cmd_display = _redact_sensitive_text(cmd_display) if tool_policy and tool_policy.blocks(block.tool_type): desc = f"{block.tool_type}: BLOCKED" @@ -2871,8 +3024,15 @@ async def stream_agent_loop( evt = await _progress_q.get() if evt is None: break + # Redact secrets in the live tail before streaming — the + # final tool_output is redacted, so the progress tail must + # be too, or a secret could flash by mid-run. Copy so we + # don't mutate the tool's own event payload. + _evt = dict(evt) + if isinstance(_evt.get("tail"), str): + _evt["tail"] = _redact_sensitive_text(_evt["tail"]) yield ( - f'data: {json.dumps({"type": "tool_progress", "tool": block.tool_type, "round": round_num, **evt})}\n\n' + f'data: {json.dumps({"type": "tool_progress", "tool": block.tool_type, "round": round_num, **_evt})}\n\n' ) desc, result = await _tool_task @@ -2937,6 +3097,72 @@ async def stream_agent_loop( elif "results" in result: result["results"] = _clean elif "stdout" in result: + result["stdout"] = _clean + except Exception: + pass + + # Emit doc-specific event for document tools — the frontend + # document panel handles this; no need to show content in chat. + if is_doc_tool and "action" in result: + if result["action"] == "suggest": + yield ( + f'data: {json.dumps({"type": "doc_suggestions", "doc_id": result["doc_id"], "suggestions": result["suggestions"]})}\n\n' + ) + else: + yield ( + f'data: {json.dumps({"type": "doc_update", "doc_id": result["doc_id"], "content": result["content"], "version": result["version"], "title": result.get("title", ""), "language": result.get("language")})}\n\n' + ) + + # Emit ui_control event for frontend to apply UI changes + if "ui_event" in result: + yield ( + f'data: {json.dumps({"type": "ui_control", "data": result})}\n\n' + ) + + # ask_user: the agent posed a multiple-choice question. Emit it so the + # frontend renders clickable options, then end the turn (below) and + # wait — the user's pick becomes the next message. + if "ask_user" in result: + # The question lives in the tool args. ChatMessage.to_dict() + # replays only role+content to the model next turn — tool_event + # metadata is dropped — so if the question is never in the saved + # assistant text, the model can't see it already asked and will + # loop and re-ask after the user answers. Stream it as assistant + # text (once) so it persists and is replayed. The card shows the + # options only, so this is the single visible copy of the question. + _auq = result["ask_user"] + _auq_q = (_auq.get("question") or "").strip() + if _auq_q and _auq_q not in full_response: + _auq_delta = ("\n\n" if full_response.strip() else "") + _auq_q + full_response += _auq_delta + yield 'data: ' + json.dumps({"delta": _auq_delta}) + '\n\n' + yield ( + f'data: {json.dumps({"type": "ask_user", "data": result["ask_user"]})}\n\n' + ) + _awaiting_user = True + + # update_plan: agent wrote back to the plan (ticked a step / revised). + # Push it to the frontend so the stored plan + docked window update + # live. Does NOT end the turn — the agent keeps working. + if "plan_update" in result: + yield ( + f'data: {json.dumps({"type": "plan_update", "data": result["plan_update"]})}\n\n' + ) + + # Build output for frontend tool bubble. + # Document tools get a short summary — content goes to the editor panel. + output_text = "" + if is_doc_tool and "action" in result: + action = result["action"] + title = result.get("title", "") + ver = result.get("version", "?") + if action == "create": + output_text = f'Document created: "{title}" (v{ver})' + elif action == "edit": + output_text = f'Document edited: "{title}" (v{ver}, {result.get("applied", 0)} edit(s))' + elif action == "update": + output_text = f'Document updated: "{title}" (v{ver})' + elif "stdout" in result: # On a bash/python timeout the result carries error + (often # empty) stdout/stderr; fall back to the error so the "timed # out" reason reaches the UI instead of a blank result. diff --git a/tests/test_loop_guard_signals.py b/tests/test_loop_guard_signals.py index 59680a145..87afeb357 100644 --- a/tests/test_loop_guard_signals.py +++ b/tests/test_loop_guard_signals.py @@ -12,6 +12,9 @@ no sleeps) and assert the structured stop event is emitted. import asyncio import json +import logging + +import pytest import src.agent_loop as al @@ -97,6 +100,37 @@ def test_no_intent_nudge_exhausted_on_normal_finish(monkeypatch): assert not any(e.get("type") == "intent_nudge_exhausted" for e in events), events +def _assert_guard_log_safe(caplog, *, structural, secret="secret123"): + """The guard's own structural log line fired, and that record carries no raw + secret. Scoped to the guard's records on purpose: an unrelated, pre-existing + round-summary log echoes raw model text and is out of scope for this PR.""" + records = [r for r in caplog.records if structural in r.getMessage()] + assert records, caplog.text + for r in records: + assert secret not in r.getMessage(), r.getMessage() + + +def test_intent_nudge_logging_does_not_leak_secret(monkeypatch, caplog): + # The model announces an action (no tool call) with a secret in the text. + # The nudge logger must record only structural metadata, never the matched + # phrase — so the credential never lands in journalctl. + _patch_common(monkeypatch) + with caplog.at_level(logging.INFO, logger="src.agent_loop"): + events = _run_loop(monkeypatch, "Let me check api_key=secret123 now", max_rounds=5) + assert any(e.get("type") == "intent_nudge_exhausted" for e in events), events + _assert_guard_log_safe(caplog, structural="intent-without-action nudge") + + +def test_loop_breaker_logging_does_not_leak_secret(monkeypatch, caplog): + # A repeated tool command carrying a secret trips the loop-breaker. The + # structural log must not contain `_sig` / raw tool-call content. + _patch_common(monkeypatch) + with caplog.at_level(logging.INFO, logger="src.agent_loop"): + events = _run_loop(monkeypatch, "```bash\necho api_key=secret123\n```", max_rounds=8) + assert any(e.get("type") == "loop_breaker_triggered" for e in events), events + _assert_guard_log_safe(caplog, structural="loop-breaker tripped") + + def test_redacts_sensitive_tool_output_before_surfacing(): text = al._redact_sensitive_text( "password: private-value\n" @@ -112,3 +146,205 @@ def test_redacts_sensitive_tool_output_before_surfacing(): assert "api_key=[redacted]" in text assert "Authorization: Bearer [redacted]" in text assert "normal output" in text + + +_GCP_API_KEY_SAMPLE = "AI" + "za" + ("A" * 35) + +# (input, secret substring that must be gone, expected substring that must remain) +_REDACTION_CASES = [ + ("Authorization: Bearer abc123tok", "abc123tok", "Authorization: Bearer [redacted]"), + ("Authorization: Basic dXNlcjpwYXNz", "dXNlcjpwYXNz", "Authorization: Basic [redacted]"), + # Quoted Authorization value (spaces) must be redacted whole. + ('Authorization: Bearer "two word secret"', "two word secret", "Authorization: Bearer [redacted]"), + # Escaped quote inside a quoted secret must not leak the tail. + (r'password="abc\"def secret"', "def secret", "password=[redacted]"), + # URL password containing a colon must still be redacted whole. + ("postgres://user:pa:ss@host/db", "pa:ss", "postgres://[redacted]@host/db"), + # Provider-shaped bare tokens. + ("token is hf_abcdefghij1234567890XYZ", "hf_abcdefghij1234567890XYZ", "[redacted]"), + ("key " + _GCP_API_KEY_SAMPLE, _GCP_API_KEY_SAMPLE, "[redacted]"), + ("Cookie: session=abc123secret", "abc123secret", "Cookie: [redacted]"), + ("Set-Cookie: sid=xyz789; HttpOnly", "xyz789", "Set-Cookie: [redacted]"), + ("postgres://user:pa55word@host/db", "pa55word", "postgres://[redacted]@host/db"), + ("client_secret=supersecretvalue", "supersecretvalue", "client_secret=[redacted]"), + ("OPENAI_API_KEY=abcd1234deadbeef", "abcd1234deadbeef", "OPENAI_API_KEY=[redacted]"), + # Quoted multi-word env value must be fully redacted, not clipped at the space. + ('OPENAI_API_KEY="two word secret"', "two word secret", "OPENAI_API_KEY=[redacted]"), + ('password: "my secret value"', "my secret value", "password: [redacted]"), + ("here is sk-abcdefghij1234567890", "sk-abcdefghij1234567890", "[redacted]"), + ( + "-----BEGIN PRIVATE KEY-----\nMIIfakeKEYbody\n-----END PRIVATE KEY-----", + "MIIfakeKEYbody", + "[redacted private key]", + ), +] + + +@pytest.mark.parametrize("raw, secret, expected", _REDACTION_CASES) +def test_redaction_covers_requested_secret_shapes(raw, secret, expected): + out = al._redact_sensitive_text(raw) + assert secret not in out, out + assert expected in out, out + + +@pytest.mark.parametrize("raw", [ + "the build completed in 3.2s with 0 errors", + "password reset email sent to the user", + "Listing 5 files: a.py b.py c.py d.py e.py", + "https://example.com/path?page=2", + # Benign uppercase names that merely end in KEY must not be redacted. + "MONKEY=banana", + "TURKEY=dinner", +]) +def test_redaction_keeps_normal_output_readable(raw): + assert al._redact_sensitive_text(raw) == raw + + +def test_redacts_before_truncating(): + # A secret near the start must be gone even if truncation would otherwise + # only clip the tail — redaction runs first. + raw = "api_key=topsecretvalue " + ("x" * 50_000) + out = al._truncate(al._redact_sensitive_text(raw)) + assert "topsecretvalue" not in out + assert "api_key=[redacted]" in out + + +def _run_tool_result(monkeypatch, tool, exec_result, max_rounds=2): + """Drive one tool round whose execution returns `exec_result`, and collect + the streamed events. Used to assert restored per-tool-result emissions.""" + _patch_common(monkeypatch) + + async def _fake_exec(block, *a, **k): + return (tool, exec_result) + monkeypatch.setattr(al, "execute_tool_block", _fake_exec, raising=False) + + round_text = f"```{tool}\n{{}}\n```" + + async def _fake_stream(_candidates, messages, **kwargs): + yield f'data: {json.dumps({"delta": round_text})}\n\n' + yield "data: [DONE]\n\n" + monkeypatch.setattr(al, "stream_llm_with_fallback", _fake_stream, raising=False) + + gen = al.stream_agent_loop( + "http://x/v1", "m", + [{"role": "user", "content": "do something"}], + max_rounds=max_rounds, + relevant_tools={tool}, + ) + return _types(_collect(gen)) + + +def test_restores_doc_suggestions_event(monkeypatch): + events = _run_tool_result( + monkeypatch, "suggest_document", + {"action": "suggest", "doc_id": "d1", "suggestions": [{"text": "x"}], "exit_code": 0}, + ) + assert any(e.get("type") == "doc_suggestions" for e in events), events + + +def test_restores_doc_update_event(monkeypatch): + events = _run_tool_result( + monkeypatch, "edit_document", + {"action": "edit", "doc_id": "d1", "content": "body", "version": 2, + "title": "T", "language": "md", "exit_code": 0}, + ) + # A native document block also emits doc_update AFTER tool_output, so a plain + # "any doc_update" check would pass even if the restored generic block were + # gone. Prove the restored block fires BEFORE the first tool_output. + types = [e.get("type") for e in events] + assert "doc_update" in types, events + assert "tool_output" in types, events + assert types.index("doc_update") < types.index("tool_output"), types + + +def test_restores_ui_control_event(monkeypatch): + events = _run_tool_result( + monkeypatch, "ui_control", + {"ui_event": "toggle", "toggle_name": "bash", "state": "off", "exit_code": 0}, + ) + assert any(e.get("type") == "ui_control" for e in events), events + + +def test_restores_plan_update_event(monkeypatch): + events = _run_tool_result( + monkeypatch, "update_plan", + {"plan_update": {"steps": [{"text": "step", "done": True}]}, "exit_code": 0}, + ) + assert any(e.get("type") == "plan_update" for e in events), events + + +def test_restores_ask_user_event_and_persists_question(monkeypatch): + events = _run_tool_result( + monkeypatch, "ask_user", + {"ask_user": {"question": "Which option?", "options": [{"label": "A"}, {"label": "B"}]}, + "exit_code": 0}, + ) + # Exactly one ask_user event — not re-emitted on a follow-up round. + _ask_events = [e for e in events if e.get("type") == "ask_user"] + assert len(_ask_events) == 1, events + # The question is streamed as assistant text so it persists for replay. + # Upstream prepends "\n\n" when full_response already holds streamed text, + # so match on containment — and it must be streamed exactly once. + _q_deltas = [e for e in events if "Which option?" in (e.get("delta") or "")] + assert len(_q_deltas) == 1, events + # Setting `_awaiting_user` breaks the loop, so the turn does NOT advance into + # another agent round (which would emit an agent_step event) after the ask. + assert not any(e.get("type") == "agent_step" for e in events), events + + +def test_redacts_command_display_in_streamed_events(monkeypatch): + # A tool command line can carry a secret. The streamed command display + # (tool_start / tool_output) must be redacted, even though the real command + # passed to execution is left untouched. + _patch_common(monkeypatch) + + round_text = "```bash\necho api_key=secret123\n```" + + async def _fake_stream(_candidates, messages, **kwargs): + yield f'data: {json.dumps({"delta": round_text})}\n\n' + yield "data: [DONE]\n\n" + monkeypatch.setattr(al, "stream_llm_with_fallback", _fake_stream, raising=False) + + gen = al.stream_agent_loop( + "http://x/v1", "m", + [{"role": "user", "content": "run it"}], + max_rounds=2, + relevant_tools={"bash"}, + ) + events = _types(_collect(gen)) + cmds = [e for e in events if e.get("type") in ("tool_start", "tool_output")] + assert cmds, events + assert all("secret123" not in (e.get("command") or "") for e in cmds), cmds + assert any("api_key=[redacted]" in (e.get("command") or "") for e in cmds), cmds + + +def test_redacts_live_tool_progress_tail(monkeypatch): + # A secret in the live progress tail must be redacted before streaming — + # otherwise it flashes by before the (already redacted) final tool_output. + _patch_common(monkeypatch) + + async def _fake_exec(block, *a, **k): + await k["progress_cb"]({"tail": "api_key=secret123", "elapsed_s": 1}) + return ("bash", {"output": "done", "exit_code": 0}) + monkeypatch.setattr(al, "execute_tool_block", _fake_exec, raising=False) + + round_text = "```bash\necho hi\n```" + + async def _fake_stream(_candidates, messages, **kwargs): + yield f'data: {json.dumps({"delta": round_text})}\n\n' + yield "data: [DONE]\n\n" + monkeypatch.setattr(al, "stream_llm_with_fallback", _fake_stream, raising=False) + + gen = al.stream_agent_loop( + "http://x/v1", "m", + [{"role": "user", "content": "run it"}], + max_rounds=2, + relevant_tools={"bash"}, + ) + events = _types(_collect(gen)) + prog = [e for e in events if e.get("type") == "tool_progress"] + assert prog, events + assert all("secret123" not in (e.get("tail") or "") for e in prog), prog + assert any("api_key=[redacted]" in (e.get("tail") or "") for e in prog), prog + # Other fields are preserved. + assert any(e.get("elapsed_s") == 1 for e in prog), prog