Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
35 changes: 24 additions & 11 deletions agent/agent_runtime_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1678,31 +1678,46 @@ def extract_reasoning(agent, assistant_message) -> Optional[str]:
Combined reasoning text, or None if no reasoning found
"""
reasoning_parts = []
reasoning_seen: set[str] = set()

def _append_reasoning_part(value) -> None:
pending = [value]
while pending:
item = pending.pop()
if isinstance(item, str):
if item and item not in reasoning_seen:
reasoning_seen.add(item)
reasoning_parts.append(item)
continue
if isinstance(item, list):
pending.extend(reversed(item))
continue
if isinstance(item, dict):
pending.extend(
item.get(key)
for key in reversed(("summary", "thinking", "content", "text"))
)

# Check direct reasoning field
if hasattr(assistant_message, 'reasoning') and assistant_message.reasoning:
reasoning_parts.append(assistant_message.reasoning)
_append_reasoning_part(assistant_message.reasoning)

# Check reasoning_content field (alternative name used by some providers)
if hasattr(assistant_message, 'reasoning_content') and assistant_message.reasoning_content:
# Don't duplicate if same as reasoning
if assistant_message.reasoning_content not in reasoning_parts:
reasoning_parts.append(assistant_message.reasoning_content)
_append_reasoning_part(assistant_message.reasoning_content)

# Check reasoning_details array (OpenRouter unified format)
# Format: [{"type": "reasoning.summary", "summary": "...", ...}, ...]
if hasattr(assistant_message, 'reasoning_details') and assistant_message.reasoning_details:
for detail in assistant_message.reasoning_details:
if isinstance(detail, dict):
# Extract summary from reasoning detail object
summary = (
_append_reasoning_part(
detail.get('summary')
or detail.get('thinking')
or detail.get('content')
or detail.get('text')
)
if summary and summary not in reasoning_parts:
reasoning_parts.append(summary)

# Some providers embed reasoning directly inside assistant content
# instead of returning structured reasoning fields. Only fall back
Expand All @@ -1719,8 +1734,7 @@ def extract_reasoning(agent, assistant_message) -> Optional[str]:
if isinstance(block, dict) and block.get("type") == "thinking":
thinking_text = block.get("thinking") or block.get("text") or ""
thinking_text = thinking_text.strip()
if thinking_text and thinking_text not in reasoning_parts:
reasoning_parts.append(thinking_text)
_append_reasoning_part(thinking_text)
if not reasoning_parts and isinstance(content, str) and content:
inline_patterns = (
r"<think>(.*?)</think>",
Expand All @@ -1733,8 +1747,7 @@ def extract_reasoning(agent, assistant_message) -> Optional[str]:
flags = re.DOTALL | re.IGNORECASE
for block in re.findall(pattern, content, flags=flags):
cleaned = block.strip()
if cleaned and cleaned not in reasoning_parts:
reasoning_parts.append(cleaned)
_append_reasoning_part(cleaned)

# Combine all reasoning parts
if reasoning_parts:
Expand Down
9 changes: 7 additions & 2 deletions agent/bedrock_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -784,7 +784,11 @@ def normalize_converse_response(response: Dict) -> SimpleNamespace:
role="assistant",
content="\n".join(text_parts) if text_parts else None,
tool_calls=tool_calls if tool_calls else None,
reasoning_content="\n\n".join(reasoning_parts) if reasoning_parts else None,
# Bedrock exposes a mutable text projection of reasoningContent. The
# adapter does not retain provider-owned replay material here, so this
# must not be labeled as an exact reasoning_content echo.
reasoning="\n\n".join(reasoning_parts) if reasoning_parts else None,
reasoning_content=None,
)

# Build usage stats. Converse's inputTokens excludes cache read/write
Expand Down Expand Up @@ -979,7 +983,8 @@ def stream_converse_with_callbacks(
role="assistant",
content="\n".join(text_parts) if text_parts else None,
tool_calls=tool_calls if tool_calls else None,
reasoning_content="\n\n".join(reasoning_parts) if reasoning_parts else None,
reasoning="\n\n".join(reasoning_parts) if reasoning_parts else None,
reasoning_content=None,
)

input_tokens = usage_data.get("inputTokens", 0)
Expand Down
47 changes: 38 additions & 9 deletions agent/chat_completion_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1387,7 +1387,6 @@ def build_api_kwargs(agent, api_messages: list, tools_for_api: list | None = Non
)



def build_assistant_message(agent, assistant_message, finish_reason: str) -> dict:
"""Build a normalized assistant message dict from an API response message.

Expand All @@ -1408,6 +1407,13 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic
combined = "\n\n".join(b.strip() for b in think_blocks if b.strip())
reasoning_text = combined or None

# Mutable reasoning is safe to mask before it reaches non-streaming
# callbacks or persistence. Provider-native replay carriers are handled
# separately below and remain unchanged.
if isinstance(reasoning_text, str) and reasoning_text:
from agent.redact import redact_sensitive_text
reasoning_text = redact_sensitive_text(reasoning_text)

if reasoning_text and agent.verbose_logging:
logging.debug(f"Captured reasoning ({len(reasoning_text)} chars): {reasoning_text}")

Expand Down Expand Up @@ -1482,6 +1488,8 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic
if isinstance(model_extra, dict) and "reasoning_content" in model_extra:
raw_reasoning_content = model_extra["reasoning_content"]
if raw_reasoning_content is not None:
# DeepSeek, Kimi, and MiMo may require this exact provider-owned value
# on the next turn, so only retain the existing surrogate cleanup.
msg["reasoning_content"] = _sanitize_surrogates(raw_reasoning_content)
elif assistant_tool_calls and agent._needs_thinking_reasoning_pad():
# DeepSeek v4 thinking mode and Kimi / Moonshot thinking mode
Expand Down Expand Up @@ -1525,8 +1533,10 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic
if hasattr(assistant_message, 'reasoning_details') and assistant_message.reasoning_details:
# Pass reasoning_details back unmodified so providers (OpenRouter,
# Anthropic, OpenAI) can maintain reasoning continuity across turns.
# Each provider may include opaque fields (signature, encrypted_content)
# that must be preserved exactly.
# Even an unsigned text/summary block is provider-owned replay state;
# changing it can invalidate a later request. Opaque fields such as
# signatures and encrypted_content make this requirement obvious, but
# are not the only shapes that require exact preservation.
raw_details = assistant_message.reasoning_details
preserved = []
for d in raw_details:
Expand Down Expand Up @@ -2446,7 +2456,13 @@ def cleanup_task_resources(agent, task_id: str) -> None:


def _build_partial_stream_stub(
role, full_content, full_reasoning, model_name, usage_obj, *,
role,
full_content,
full_reasoning,
model_name,
usage_obj,
*,
full_reasoning_content=None,
dropped_tool_names=None,
):
"""Build a partial-stream-stub response for mid-stream drop scenarios.
Expand All @@ -2461,7 +2477,8 @@ def _build_partial_stream_stub(
role=role,
content=full_content,
tool_calls=None,
reasoning_content=full_reasoning,
reasoning=full_reasoning,
reasoning_content=full_reasoning_content,
)
mock_choice = SimpleNamespace(
index=0,
Expand Down Expand Up @@ -3024,6 +3041,7 @@ def _call_chat_completions(stream_attempt_id: int):
model_name = None
role = "assistant"
reasoning_parts: list = []
reasoning_content_parts: list = []
usage_obj = None
_diag = agent._stream_diag_init()
request_client_holder["diag"] = _diag
Expand Down Expand Up @@ -3103,7 +3121,8 @@ def _relay_final_response() -> dict[str, Any]:
"message": {
"role": role,
"content": "".join(content_parts) or None,
"reasoning_content": "".join(reasoning_parts) or None,
"reasoning": "".join(reasoning_parts) or None,
"reasoning_content": "".join(reasoning_content_parts) or None,
"tool_calls": tool_calls or None,
},
"finish_reason": finish_reason or "stop",
Expand Down Expand Up @@ -3206,9 +3225,14 @@ def _relay_final_response() -> dict[str, Any]:
model_name = chunk.model

# Accumulate reasoning content
reasoning_text = getattr(delta, "reasoning_content", None) or getattr(delta, "reasoning", None)
provider_reasoning_text = getattr(delta, "reasoning_content", None)
mutable_reasoning_text = getattr(delta, "reasoning", None)
reasoning_text = provider_reasoning_text or mutable_reasoning_text
if reasoning_text:
reasoning_parts.append(reasoning_text)
if provider_reasoning_text:
reasoning_content_parts.append(provider_reasoning_text)
if mutable_reasoning_text:
reasoning_parts.append(mutable_reasoning_text)
_fire_first_delta()
agent._fire_reasoning_delta(reasoning_text)

Expand Down Expand Up @@ -3403,6 +3427,7 @@ def _relay_final_response() -> dict[str, Any]:
finish_reason is None
and not content_parts
and not reasoning_parts
and not reasoning_content_parts
and not tool_calls_acc
):
raise EmptyStreamError(
Expand Down Expand Up @@ -3446,6 +3471,7 @@ def _relay_final_response() -> dict[str, Any]:
role, full_content,
"".join(reasoning_parts) or None,
model_name, usage_obj,
full_reasoning_content="".join(reasoning_content_parts) or None,
dropped_tool_names=_dropped_names or None,
)

Expand All @@ -3468,18 +3494,21 @@ def _relay_final_response() -> dict[str, Any]:
role, full_content,
"".join(reasoning_parts) or None,
model_name, usage_obj,
full_reasoning_content="".join(reasoning_content_parts) or None,
)

effective_finish_reason = finish_reason or "stop"
if has_truncated_tool_args:
effective_finish_reason = "length"

full_reasoning = "".join(reasoning_parts) or None
full_reasoning_content = "".join(reasoning_content_parts) or None
mock_message = SimpleNamespace(
role=role,
content=full_content,
tool_calls=mock_tool_calls,
reasoning_content=full_reasoning,
reasoning=full_reasoning,
reasoning_content=full_reasoning_content,
)
mock_choice = SimpleNamespace(
index=0,
Expand Down
5 changes: 4 additions & 1 deletion agent/copilot_acp_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -487,7 +487,10 @@ def _create_chat_completion(
content=cleaned_text,
tool_calls=tool_calls,
reasoning=reasoning_text or None,
reasoning_content=reasoning_text or None,
# ACP exposes a mutable reasoning transcript, not provider-owned
# replay state. Keep it out of reasoning_content so persistence
# masking cannot be bypassed by a synthetic duplicate.
reasoning_content=None,
reasoning_details=None,
)
finish_reason = "tool_calls" if tool_calls else "stop"
Expand Down
6 changes: 4 additions & 2 deletions agent/gemini_native_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -626,7 +626,10 @@ def translate_gemini_response(resp: Dict[str, Any], model: str) -> SimpleNamespa
content="".join(text_pieces) if text_pieces else None,
tool_calls=tool_calls or None,
reasoning=reasoning,
reasoning_content=reasoning,
# Native Gemini thought text is an ordinary, mutable copy. Replay
# continuity is carried by thought_signature on tool calls, not by an
# OpenAI-compatible reasoning_content echo.
reasoning_content=None,
reasoning_details=None,
)
choice = SimpleNamespace(index=0, message=message, finish_reason=finish_reason)
Expand Down Expand Up @@ -677,7 +680,6 @@ def _make_stream_chunk(
delta_kwargs["tool_calls"] = [tool_delta]
if reasoning:
delta_kwargs["reasoning"] = reasoning
delta_kwargs["reasoning_content"] = reasoning
delta = SimpleNamespace(**delta_kwargs)
choice = SimpleNamespace(index=0, delta=delta, finish_reason=finish_reason)
return _GeminiStreamChunk(
Expand Down
54 changes: 36 additions & 18 deletions agent/redact.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,19 +329,39 @@ def _key_has_secret_keyword(key: str) -> bool:
r"-----BEGIN[A-Z ]*PRIVATE KEY-----[\s\S]*?-----END[A-Z ]*PRIVATE KEY-----"
)

# Database connection strings: protocol://user:PASSWORD@host
# Catches postgres, mysql, mongodb, redis, amqp URLs and redacts the password.
# The userinfo and password groups forbid whitespace ([^:\s]+ / [^@\s]+) so the
# match can never span a line break. A real DSN password never contains
# whitespace; without this bound the greedy [^@]+ would scan past the end of a
# code line to the next stray "@" (e.g. a Python decorator), swallowing
# intervening lines and corrupting tool OUTPUT for any source containing a
# postgresql:// f-string template. See issue #33801.
# Database connection strings: dialect[+driver]://[user]:PASSWORD@host
# Catches common SQL, document, cache, and queue database URI schemes and
# redacts the password. The optional driver segment covers SQLAlchemy-style
# schemes such as postgresql+psycopg, snowflake+connector, and db2+ibm_db.
# The username may be empty for URI forms such as redis://:password@host.
#
# The username and password groups stop at quotes and structured-text
# delimiters as well as URI authority delimiters. That prevents a passwordless
# host:port string in one JSON/YAML value from scanning into a later email
# address. URI punctuation such as commas, semicolons, and parentheses remains
# valid inside a password; quote/bracket characters must be percent-encoded.
#
# A permissive password group used to scan past the end of a code line to the
# next stray "@" (e.g. a Python decorator), swallowing intervening lines and
# corrupting tool output for source containing a postgresql:// f-string
# template. The leading guard also keeps a database name from matching inside
# a longer custom scheme. See issues #33801 and #43666.
_DB_CONNSTR_RE = re.compile(
r"((?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp)://[^:\s]+:)([^@\s]+)(@)",
r"(?<![a-z0-9+.-])"
r"((?:postgres(?:ql)?|mysqlx?|mariadb|mongodb|mssql|oracle|clickhouse|"
r"cockroachdb|snowflake|trino|db2|rediss?|amqps?)"
r"(?:\+[a-z0-9_.-]+)?://[^:/?#\s@,;'\"<>\[\]{}()\\|]*:)"
r"([^@/?#\s'\"<>\[\]{}\\|]+)(@)",
re.IGNORECASE,
)


def _redact_db_connstr_match(match: re.Match, *, code_file: bool) -> str:
password = match.group(2)
if code_file and password.startswith("{") and password.endswith("}"):
return match.group(0)
return f"{match.group(1)}***{match.group(3)}"

# Bare-token credential in a web/transport URL: ``scheme://TOKEN@host``.
# This is the ``git remote set-url origin https://PASSWORD@github.com/...``
# shape from issue #6396 — a single opaque credential in the userinfo position
Expand Down Expand Up @@ -822,15 +842,13 @@ def _redact_telegram(m):
# forbids whitespace in the password group, so a single-line template's
# group(2) is exactly the brace expression. See issue #33801.
if "://" in text:
if code_file:
def _redact_db(m):
pw = m.group(2)
if pw.startswith("{") and pw.endswith("}"):
return m.group(0)
return f"{m.group(1)}***{m.group(3)}"
text = _DB_CONNSTR_RE.sub(_redact_db, text)
else:
text = _DB_CONNSTR_RE.sub(lambda m: f"{m.group(1)}***{m.group(3)}", text)
text = _DB_CONNSTR_RE.sub(
lambda match: _redact_db_connstr_match(
match,
code_file=code_file,
),
text,
)

# Bare-token userinfo in web/transport URLs: ``scheme://TOKEN@host``.
# The git-remote-with-embedded-password shape from #6396. Only the
Expand Down
Loading
Loading