mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-22 23:02:23 +02:00
fix(security): eliminate induced regex denial-of-service paths
This commit is contained in:
+147
-28
@@ -86,6 +86,8 @@ from src.tool_parsing import iter_email_addresses, strip_angle_tags
|
|||||||
from src.text_scanning import (
|
from src.text_scanning import (
|
||||||
contains_detailed_sequence_request,
|
contains_detailed_sequence_request,
|
||||||
has_prefixed_token_match,
|
has_prefixed_token_match,
|
||||||
|
space_delimited_fields,
|
||||||
|
terminal_dot_field,
|
||||||
first_tag_content,
|
first_tag_content,
|
||||||
iter_angle_contents,
|
iter_angle_contents,
|
||||||
iter_markdown_links,
|
iter_markdown_links,
|
||||||
@@ -2155,7 +2157,7 @@ def _looks_like_agent_reasoning_preamble(text: str) -> bool:
|
|||||||
r"(?:let me|i(?:'ll| will)(?:\s+need\s+to)?|i\s+can(?:\s+now)?|i(?:'m| am)\s+(?:preparing|planning)\s+to)\s+"
|
r"(?:let me|i(?:'ll| will)(?:\s+need\s+to)?|i\s+can(?:\s+now)?|i(?:'m| am)\s+(?:preparing|planning)\s+to)\s+"
|
||||||
r"(?:(?:carefully|methodically|systematically|closely|further)\s+){0,2}"
|
r"(?:(?:carefully|methodically|systematically|closely|further)\s+){0,2}"
|
||||||
r"(?:continue|continuing|analy[sz]e|scan|check|verify|inspect|confirm|look up|fetch|open|list|search|refine|request|review|track|read|watch|(?:re-?)?examine|provide|give|state|report|answer|respond|summarize|conclude)\b"
|
r"(?:continue|continuing|analy[sz]e|scan|check|verify|inspect|confirm|look up|fetch|open|list|search|refine|request|review|track|read|watch|(?:re-?)?examine|provide|give|state|report|answer|respond|summarize|conclude)\b"
|
||||||
r"[^.!?]*[.!?]?\s*$",
|
r"[^.!?]*+[.!?]?\s*$",
|
||||||
trailing_segment,
|
trailing_segment,
|
||||||
):
|
):
|
||||||
return True
|
return True
|
||||||
@@ -2475,6 +2477,91 @@ def _parse_qwen_explicit_note_delete(text: str) -> Optional[str]:
|
|||||||
return match.group(1).strip().strip("\"'`").rstrip(".") if match else None
|
return match.group(1).strip().strip("\"'`").rstrip(".") if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def _space_field_leaders(patterns, flags=re.IGNORECASE):
|
||||||
|
return [(re.compile(pattern, flags), minimum) for pattern, minimum in patterns]
|
||||||
|
|
||||||
|
|
||||||
|
def _payload_fields(value, grammar, flags=re.IGNORECASE):
|
||||||
|
"""Scan the finite command payload grammars without whitespace partitions."""
|
||||||
|
simple = [(r"\s++", 1)]
|
||||||
|
the = [(r"\s++the\s++", 1), *simple]
|
||||||
|
if grammar == "note_update":
|
||||||
|
leaders = simple
|
||||||
|
separator = r"(?<!\s)(\s++)so\s++its\s++content\s++is(?=\s)"
|
||||||
|
quoted = re.compile(r"\s++['\"]([^'\"]+)['\"]", flags)
|
||||||
|
def tail(match):
|
||||||
|
result = quoted.match(value, match.end())
|
||||||
|
return result.groups() if result else None
|
||||||
|
elif grammar == "email_mutation":
|
||||||
|
leaders = [
|
||||||
|
(r"\s++(?:all|every|the)\s*+my\s++", 1),
|
||||||
|
(r"\s++(?:all|every|the)\s*+", 0),
|
||||||
|
(r"\s++my\s++", 1), *simple,
|
||||||
|
]
|
||||||
|
separator = r"(?<!\s)(\s++)(?:emails?|mail|messages?)\b"
|
||||||
|
tail = lambda match: ()
|
||||||
|
elif grammar in ("checklist", "replace", "change"):
|
||||||
|
leaders = simple
|
||||||
|
word = "to" if grammar == "change" else "with"
|
||||||
|
separator = rf"(?<!\s)(\s++){word}"
|
||||||
|
if grammar == "checklist":
|
||||||
|
last_newline = value.rfind("\n", 0, len(value) - int(value.endswith("\n")))
|
||||||
|
tail = lambda match: terminal_dot_field(value, match.end(), last_newline=last_newline)
|
||||||
|
else:
|
||||||
|
markers = list(re.finditer(r"(?<!\s)(\s++)(?:in|and|then|before)\b|[.;]|$", value, flags))
|
||||||
|
marker_index = 0
|
||||||
|
previous_start = -1
|
||||||
|
newline = value.find("\n")
|
||||||
|
def tail(match):
|
||||||
|
nonlocal marker_index, previous_start, newline
|
||||||
|
start = match.end()
|
||||||
|
if start >= len(value) or not value[start].isspace():
|
||||||
|
return None
|
||||||
|
cursor = start
|
||||||
|
while cursor < len(value) and value[cursor].isspace():
|
||||||
|
cursor += 1
|
||||||
|
if start < previous_start:
|
||||||
|
marker_index = 0
|
||||||
|
newline = value.find("\n")
|
||||||
|
previous_start = start
|
||||||
|
while marker_index < len(markers) and markers[marker_index].start() <= cursor:
|
||||||
|
marker_index += 1
|
||||||
|
if 0 <= newline < cursor:
|
||||||
|
newline = value.find("\n", cursor)
|
||||||
|
if marker_index < len(markers):
|
||||||
|
end = markers[marker_index].start()
|
||||||
|
if newline < 0 or newline >= end:
|
||||||
|
return (value[cursor:end],)
|
||||||
|
# Only after the greedy whitespace-led field fails may its
|
||||||
|
# leading run give a single dot character back to the value.
|
||||||
|
prior = marker_index - 1
|
||||||
|
if prior >= 0:
|
||||||
|
marker = markers[prior]
|
||||||
|
if marker.start() <= cursor and (
|
||||||
|
marker.start() == cursor or marker.end(1) == cursor
|
||||||
|
):
|
||||||
|
candidate = cursor - (2 if marker.group(1) else 1)
|
||||||
|
while candidate > start and value[candidate] == "\n":
|
||||||
|
candidate -= 1
|
||||||
|
if candidate > start:
|
||||||
|
return (value[candidate:candidate + 1],)
|
||||||
|
return None
|
||||||
|
elif grammar == "tag":
|
||||||
|
leaders = the
|
||||||
|
separator = r"(?<!\s)(\s++)tag\s++to(?=\s)"
|
||||||
|
tag = re.compile(r"\s++#?([a-z][a-z0-9_-]{1,30})\b", flags)
|
||||||
|
def tail(match):
|
||||||
|
result = tag.match(value, match.end())
|
||||||
|
return result.groups() if result else None
|
||||||
|
elif grammar == "remaining_checklist":
|
||||||
|
leaders = the
|
||||||
|
separator = r"(?<!\s)(\s++)checklist\b"
|
||||||
|
tail = lambda match: ()
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown payload grammar: {grammar}")
|
||||||
|
return space_delimited_fields(value, _space_field_leaders(leaders, flags), re.compile(separator, flags), tail)
|
||||||
|
|
||||||
|
|
||||||
def _captures_after_first_prefix(
|
def _captures_after_first_prefix(
|
||||||
value: str,
|
value: str,
|
||||||
prefix_pattern: str,
|
prefix_pattern: str,
|
||||||
@@ -2482,12 +2569,20 @@ def _captures_after_first_prefix(
|
|||||||
*,
|
*,
|
||||||
flags: int = re.IGNORECASE,
|
flags: int = re.IGNORECASE,
|
||||||
) -> tuple[str | None, ...] | None:
|
) -> tuple[str | None, ...] | None:
|
||||||
"""Match a suffix once after the first prefix that can own all later text."""
|
"""Match the staged production payload grammars with forward scans."""
|
||||||
prefix = re.search(prefix_pattern, value, flags)
|
prefix = re.search(prefix_pattern, value, flags)
|
||||||
if prefix is None:
|
if prefix is None:
|
||||||
return None
|
return None
|
||||||
remainder = re.match(remainder_pattern, value[prefix.end():], flags)
|
grammars = {
|
||||||
return remainder.groups() if remainder is not None else None
|
r"\s+(.+?)\s+so\s+its\s+content\s+is\s+['\"]([^'\"]+)['\"]": "note_update",
|
||||||
|
r"\s+(?:all|every|the)?\s*(?:my\s+)?(.+?)\s+(?:emails?|mail|messages?)\b": "email_mutation",
|
||||||
|
r"\s+(.+?)\s+with\s+(.+?)$": "checklist",
|
||||||
|
r"\s+(?:the\s+)?(.+?)\s+tag\s+to\s+#?([a-z][a-z0-9_-]{1,30})\b": "tag",
|
||||||
|
r"\s+(.+?)\s+to\s+(.+?)(?=\s+(?:in|and|then|before)\b|[.;]|$)": "change",
|
||||||
|
r"\s+(.+?)\s+with\s+(.+?)(?=\s+(?:in|and|then|before)\b|[.;]|$)": "replace",
|
||||||
|
r"\s+(?:all)?\s*(.+?)\s+emails?\b": "email_mutation",
|
||||||
|
}
|
||||||
|
return _payload_fields(value[prefix.end():], grammars[remainder_pattern], flags)
|
||||||
|
|
||||||
|
|
||||||
def _parse_qwen_explicit_note_update(text: str) -> Optional[tuple[str, str]]:
|
def _parse_qwen_explicit_note_update(text: str) -> Optional[tuple[str, str]]:
|
||||||
@@ -2605,14 +2700,14 @@ def _pipeline_request_parts(value: str) -> tuple[str, str, str, str] | None:
|
|||||||
)
|
)
|
||||||
if prefix is None:
|
if prefix is None:
|
||||||
return None
|
return None
|
||||||
remainder = re.match(
|
rest = value[prefix.end():]
|
||||||
r"\s+(.+?),\s*then\s+([^\s,]+)\s+to\s+(.+?)(?:[.!?]\s*)?$",
|
last_newline = rest.rfind("\n", 0, len(rest) - int(rest.endswith("\n")))
|
||||||
value[prefix.end():],
|
separator = re.compile(r"(),\s*+then\s++([^\s,]+)\s++to(?=\s)", re.I)
|
||||||
re.IGNORECASE,
|
def tail(match):
|
||||||
)
|
final = terminal_dot_field(rest, match.end(), punctuation=True, last_newline=last_newline)
|
||||||
if remainder is None:
|
return (match.group(2), *final) if final is not None else None
|
||||||
return None
|
fields = space_delimited_fields(rest, _space_field_leaders([(r"\s++", 1)]), separator, tail)
|
||||||
return prefix.group(1), remainder.group(1), remainder.group(2), remainder.group(3)
|
return (prefix.group(1), *fields) if fields is not None else None
|
||||||
|
|
||||||
|
|
||||||
def _parse_explicit_pipeline_request(text: str) -> Optional[tuple[str, str]]:
|
def _parse_explicit_pipeline_request(text: str) -> Optional[tuple[str, str]]:
|
||||||
@@ -3018,7 +3113,7 @@ def _parse_qwen_explicit_session_find(text: str) -> Optional[tuple[str, str]]:
|
|||||||
):
|
):
|
||||||
return None
|
return None
|
||||||
if re.search(
|
if re.search(
|
||||||
r"\b(?:list|show|view)\b.{0,30}\b(?:my\s+)?(?:recent|latest|all)?\s*(?:chats?|sessions?|conversations?)\b"
|
r"\b(?:list|show|view)\b.{0,30}\b(?:my\s++)?(?:recent|latest|all)?\s*+(?:chats?|sessions?|conversations?)\b"
|
||||||
r"|\b(?:recent|latest|all)\s+(?:chats?|sessions?|conversations?)\b",
|
r"|\b(?:recent|latest|all)\s+(?:chats?|sessions?|conversations?)\b",
|
||||||
value,
|
value,
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
@@ -4519,12 +4614,8 @@ def _remaining_checklist_name(value: str) -> str | None:
|
|||||||
)
|
)
|
||||||
if location is None:
|
if location is None:
|
||||||
return None
|
return None
|
||||||
name = re.match(
|
fields = _payload_fields(value[location.end():line_end], "remaining_checklist")
|
||||||
r"\s+(?:the\s+)?(.+?)\s+checklist\b",
|
return fields[0] if fields is not None else None
|
||||||
value[location.end():line_end],
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
return name.group(1) if name is not None else None
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_simple_notes_tool_request(text: str) -> Optional[tuple[str, str]]:
|
def _parse_simple_notes_tool_request(text: str) -> Optional[tuple[str, str]]:
|
||||||
@@ -4600,7 +4691,7 @@ def _parse_simple_notes_tool_request(text: str) -> Optional[tuple[str, str]]:
|
|||||||
return "manage_notes", json.dumps({"action": "search", "query": query})
|
return "manage_notes", json.dumps({"action": "search", "query": query})
|
||||||
|
|
||||||
note_saying_match = re.search(
|
note_saying_match = re.search(
|
||||||
r"\b(?:create|add|make|save)\s+(?:a\s+)?note\s+(?:saying|that says|with)\s+(.+?)\s*$",
|
r"\b(?:create|add|make|save)\s++(?:a\s++)?note\s++(?:saying|that says|with)\s++(.+?)$",
|
||||||
value,
|
value,
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
@@ -12924,6 +13015,38 @@ def _contextual_summary_fragment(text: str) -> str:
|
|||||||
return text[prefix.end():newline] if newline >= 0 else ""
|
return text[prefix.end():newline] if newline >= 0 else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _contextual_email_subject(text):
|
||||||
|
"""Read the legacy Subject heading without partitioning newline runs."""
|
||||||
|
for keyword in re.finditer(r"\bsubject", text, re.I):
|
||||||
|
# Optional formatting is tried in the regex's original greedy order.
|
||||||
|
# There are only twelve prefix alternatives, each with disjoint spaces.
|
||||||
|
for bold in (True, False):
|
||||||
|
for colon in (True, False):
|
||||||
|
for quote in ('"', '*"', ''):
|
||||||
|
pattern = r"\s*+" + (r"\*\*\s*+" if bold else "")
|
||||||
|
pattern += (r":\s*+" if colon else "") + re.escape(quote)
|
||||||
|
prefix = re.compile(pattern).match(text, keyword.end())
|
||||||
|
if prefix is None:
|
||||||
|
continue
|
||||||
|
start = prefix.end()
|
||||||
|
if start < len(text) and text[start] != "\n":
|
||||||
|
# The first capture character is mandatory, even when
|
||||||
|
# it is itself a quote. Only later quotes terminate it.
|
||||||
|
newline = text.find("\n", start + 1)
|
||||||
|
closer = text.find('"', start + 1)
|
||||||
|
ends = [end for end in (newline, closer) if end >= 0]
|
||||||
|
end = min(ends) if ends else len(text)
|
||||||
|
return text[start:end]
|
||||||
|
# Greedy whitespace can give back its last non-LF dot
|
||||||
|
# character. This also preserves whitespace-only subjects.
|
||||||
|
candidate = start - 1
|
||||||
|
while candidate >= keyword.end() and text[candidate].isspace():
|
||||||
|
if text[candidate] != "\n":
|
||||||
|
return text[candidate:candidate + 1]
|
||||||
|
candidate -= 1
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _contextual_reply_body_from_recent_email_context(messages: List[Dict]) -> str:
|
def _contextual_reply_body_from_recent_email_context(messages: List[Dict]) -> str:
|
||||||
"""Build a bounded draft body from the latest assistant email summary.
|
"""Build a bounded draft body from the latest assistant email summary.
|
||||||
|
|
||||||
@@ -12947,13 +13070,9 @@ def _contextual_reply_body_from_recent_email_context(messages: List[Dict]) -> st
|
|||||||
if link_match:
|
if link_match:
|
||||||
subject = _clean_fragment(link_match.group(1))
|
subject = _clean_fragment(link_match.group(1))
|
||||||
if not subject:
|
if not subject:
|
||||||
subject_match = re.search(
|
subject_fragment = _contextual_email_subject(text)
|
||||||
r"\bsubject\s*(?:\*\*)?\s*:?\s*(?:\"|\*\")?(.+?)(?:\"|\n|$)",
|
if subject_fragment is not None:
|
||||||
text,
|
subject = _clean_fragment(subject_fragment)
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
if subject_match:
|
|
||||||
subject = _clean_fragment(subject_match.group(1))
|
|
||||||
summary_fragment = _contextual_summary_fragment(text)
|
summary_fragment = _contextual_summary_fragment(text)
|
||||||
if summary_fragment:
|
if summary_fragment:
|
||||||
summary = _clean_fragment(summary_fragment)
|
summary = _clean_fragment(summary_fragment)
|
||||||
@@ -13395,7 +13514,7 @@ def _normalize_ody_qwen_text_artifacts(text: str, *, strip_edges: bool = True) -
|
|||||||
|
|
||||||
|
|
||||||
_ODY_QWEN_LEAKED_TOOL_TEXT_RE = re.compile(
|
_ODY_QWEN_LEAKED_TOOL_TEXT_RE = re.compile(
|
||||||
r"(<\s*/?\s*(?:function|parameter|tool_call)\b"
|
r"(<\s*+(?:/\s*+)?(?:function|parameter|tool_call)\b"
|
||||||
r"|\bmanage_(?:notes|calendar|memory|documents|contact)\s*\("
|
r"|\bmanage_(?:notes|calendar|memory|documents|contact)\s*\("
|
||||||
r"|\"function\"\s*:\s*\"(?:manage_|mcp__)"
|
r"|\"function\"\s*:\s*\"(?:manage_|mcp__)"
|
||||||
r"|mcp__email__"
|
r"|mcp__email__"
|
||||||
|
|||||||
@@ -208,3 +208,79 @@ def first_tag_content(text: str, tag: str, *, allow_attributes: bool = False) ->
|
|||||||
return None
|
return None
|
||||||
return text[body_start:closer.start()]
|
return text[body_start:closer.start()]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def space_delimited_fields(text, leaders, separator_re, tail):
|
||||||
|
"""Match a lazy dot field after a finite set of greedy leading grammars.
|
||||||
|
|
||||||
|
Separators consume a maximal whitespace run (group 1) and a fixed grammar.
|
||||||
|
Test each run once, rather than repartitioning it between a leading space
|
||||||
|
quantifier, a dot capture, and the separator. ``tail`` must also scan
|
||||||
|
monotonically or use only a fixed/bounded grammar.
|
||||||
|
"""
|
||||||
|
separators = list(separator_re.finditer(text))
|
||||||
|
for leader_re, minimum_space in leaders:
|
||||||
|
leader = leader_re.match(text)
|
||||||
|
if leader is None:
|
||||||
|
continue
|
||||||
|
start = leader.end()
|
||||||
|
newline = text.find("\n", start)
|
||||||
|
line_end = len(text) if newline < 0 else newline
|
||||||
|
for separator in separators:
|
||||||
|
end = separator.start(1)
|
||||||
|
if start < end <= line_end:
|
||||||
|
remainder = tail(separator)
|
||||||
|
if remainder is not None:
|
||||||
|
return (text[start:end], *remainder)
|
||||||
|
# A greedy leading run may give back a dot character when the field
|
||||||
|
# consists entirely of whitespace. Only the last non-LF character
|
||||||
|
# that leaves the mandatory separator can win; do not retry suffixes.
|
||||||
|
run_start = start
|
||||||
|
while run_start and text[run_start - 1].isspace():
|
||||||
|
run_start -= 1
|
||||||
|
earliest = run_start + minimum_space
|
||||||
|
for separator in separators:
|
||||||
|
if separator.end(1) != start:
|
||||||
|
continue
|
||||||
|
end = start - (1 if separator.group(1) else 0)
|
||||||
|
candidate = end - 1
|
||||||
|
while candidate >= earliest and text[candidate] == "\n":
|
||||||
|
candidate -= 1
|
||||||
|
if candidate >= earliest:
|
||||||
|
remainder = tail(separator)
|
||||||
|
if remainder is not None:
|
||||||
|
return (text[candidate:candidate + 1], *remainder)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def terminal_dot_field(text, start, *, punctuation=False, last_newline=None):
|
||||||
|
"""Read a whitespace-led dot field ending at Python's dollar boundary."""
|
||||||
|
if start >= len(text) or not text[start].isspace():
|
||||||
|
return None
|
||||||
|
cursor = start
|
||||||
|
while cursor < len(text) and text[cursor].isspace():
|
||||||
|
cursor += 1
|
||||||
|
end = len(text) - (1 if text.endswith("\n") else 0)
|
||||||
|
if cursor >= end:
|
||||||
|
cursor = end - 1
|
||||||
|
while cursor > start and text[cursor] == "\n":
|
||||||
|
cursor -= 1
|
||||||
|
if cursor <= start or text[cursor] == "\n":
|
||||||
|
return None
|
||||||
|
# Newlines before the field can be leading whitespace; newlines inside
|
||||||
|
# the dot capture cannot be consumed. The last LF is a constant-time veto.
|
||||||
|
if last_newline is None:
|
||||||
|
last_newline = text.rfind("\n", 0, end)
|
||||||
|
if last_newline >= cursor:
|
||||||
|
return None
|
||||||
|
capture_end = end
|
||||||
|
if punctuation:
|
||||||
|
while capture_end > cursor and text[capture_end - 1].isspace():
|
||||||
|
capture_end -= 1
|
||||||
|
if capture_end > cursor and text[capture_end - 1] in ".!?":
|
||||||
|
capture_end -= 1
|
||||||
|
else:
|
||||||
|
capture_end = end
|
||||||
|
if capture_end == cursor:
|
||||||
|
capture_end = end
|
||||||
|
return (text[cursor:capture_end],)
|
||||||
|
|||||||
+40
-6
@@ -1515,6 +1515,45 @@ def _parse_tool_code_block(raw: str) -> Optional[ToolBlock]:
|
|||||||
return ToolBlock(tool_name, content.strip())
|
return ToolBlock(tool_name, content.strip())
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
_GEMMA_FALLBACK_KEY_RE = re.compile(r"\w++")
|
||||||
|
_GEMMA_FALLBACK_COLON_RE = re.compile(r"\s*+:")
|
||||||
|
_GEMMA_FALLBACK_BOUNDARY_RE = re.compile(r"(?<!\s)(\s*+)(?:,\s*+\w++\s*+:|})")
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_gemma_fallback_items(body):
|
||||||
|
"""Preserve permissive k:v recovery with one pass over keys/boundaries."""
|
||||||
|
boundaries = list(_GEMMA_FALLBACK_BOUNDARY_RE.finditer(body))
|
||||||
|
boundary_index = 0
|
||||||
|
pos = 0
|
||||||
|
newline = body.find("\n")
|
||||||
|
while key := _GEMMA_FALLBACK_KEY_RE.search(body, pos):
|
||||||
|
colon = _GEMMA_FALLBACK_COLON_RE.match(body, key.end())
|
||||||
|
if colon is None:
|
||||||
|
pos = key.end()
|
||||||
|
continue
|
||||||
|
start = colon.end()
|
||||||
|
while start < len(body) and body[start].isspace():
|
||||||
|
start += 1
|
||||||
|
if start < len(body) and body[start] in "\"'":
|
||||||
|
start += 1
|
||||||
|
while boundary_index < len(boundaries) and boundaries[boundary_index].end(1) < start:
|
||||||
|
boundary_index += 1
|
||||||
|
if boundary_index == len(boundaries):
|
||||||
|
return
|
||||||
|
boundary = boundaries[boundary_index]
|
||||||
|
end = max(start, boundary.start())
|
||||||
|
if 0 <= newline < start:
|
||||||
|
newline = body.find("\n", start)
|
||||||
|
if newline >= 0 and newline < end:
|
||||||
|
pos = key.end()
|
||||||
|
continue
|
||||||
|
capture_end = end
|
||||||
|
if capture_end > start and body[capture_end - 1] in "\"'":
|
||||||
|
capture_end -= 1
|
||||||
|
yield key.group(0), body[start:capture_end].strip()
|
||||||
|
pos = end
|
||||||
|
|
||||||
|
|
||||||
def _parse_gemma_tool_call(tool_name: str, body: str) -> Optional[ToolBlock]:
|
def _parse_gemma_tool_call(tool_name: str, body: str) -> Optional[ToolBlock]:
|
||||||
"""Parse a Gemma-style call:tool_name{...} block into a ToolBlock."""
|
"""Parse a Gemma-style call:tool_name{...} block into a ToolBlock."""
|
||||||
tool_name = tool_name.strip().lower().replace("-", "_")
|
tool_name = tool_name.strip().lower().replace("-", "_")
|
||||||
@@ -1539,12 +1578,7 @@ def _parse_gemma_tool_call(tool_name: str, body: str) -> Optional[ToolBlock]:
|
|||||||
if not isinstance(params, dict):
|
if not isinstance(params, dict):
|
||||||
params = {}
|
params = {}
|
||||||
except Exception:
|
except Exception:
|
||||||
# Simple regex key-value extraction fallback
|
params = dict(_iter_gemma_fallback_items(body))
|
||||||
params = {}
|
|
||||||
for m in re.finditer(r'(\w+)\s*:\s*["\']?(.*?)["\']?(?=\s*,\s*\w+\s*:|\s*\})', body):
|
|
||||||
k = m.group(1)
|
|
||||||
v = m.group(2).strip()
|
|
||||||
params[k] = v
|
|
||||||
|
|
||||||
from src.tool_schemas import function_call_to_tool_block
|
from src.tool_schemas import function_call_to_tool_block
|
||||||
return function_call_to_tool_block(tool_name, json.dumps(params))
|
return function_call_to_tool_block(tool_name, json.dumps(params))
|
||||||
|
|||||||
+10
-7
@@ -2304,6 +2304,9 @@ def _terminal_clause_match(text: str, core_pattern: str) -> re.Match[str] | None
|
|||||||
while end and text[end - 1] in ".!?":
|
while end and text[end - 1] in ".!?":
|
||||||
end -= 1
|
end -= 1
|
||||||
possessive = core_pattern.replace(r"\s+", r"\s++").replace(r"\s*", r"\s*+")
|
possessive = core_pattern.replace(r"\s+", r"\s++").replace(r"\s*", r"\s*+")
|
||||||
|
# An optional leading " and" branch must start at a whitespace boundary;
|
||||||
|
# otherwise search retries the whole possessive run at every suffix.
|
||||||
|
possessive = possessive.replace(r"|\s++and", r"|(?<!\s)\s++and")
|
||||||
return re.search(possessive + r"$", text[:end], re.I)
|
return re.search(possessive + r"$", text[:end], re.I)
|
||||||
|
|
||||||
|
|
||||||
@@ -2403,7 +2406,7 @@ def _read_request_and_limit(message: str) -> tuple[str, int | None]:
|
|||||||
if safety_tail:
|
if safety_tail:
|
||||||
text = text[:safety_tail.start()].strip()
|
text = text[:safety_tail.start()].strip()
|
||||||
text = re.sub(
|
text = re.sub(
|
||||||
r"[.!?]\s*read[- ]only(?:\s+(?:please|pls|plz))?\s*,?\s*"
|
r"[.!?]\s*+read[- ]only(?:\s++(?:please|pls|plz))?\s*+(?:,\s*+)?"
|
||||||
r"(?:do\s+not|don['’]?t|dont)\s+(?:change|edit|modify)\s+"
|
r"(?:do\s+not|don['’]?t|dont)\s+(?:change|edit|modify)\s+"
|
||||||
r"(?:or\s+send\s+)?anything[.!?]*\s*$",
|
r"(?:or\s+send\s+)?anything[.!?]*\s*$",
|
||||||
"", text, flags=re.I,
|
"", text, flags=re.I,
|
||||||
@@ -2469,11 +2472,11 @@ def _read_request_and_limit(message: str) -> tuple[str, int | None]:
|
|||||||
maximum = int(raw) if raw.isdecimal() else _READ_COUNT_WORDS[raw.lower()]
|
maximum = int(raw) if raw.isdecimal() else _READ_COUNT_WORDS[raw.lower()]
|
||||||
text = text[:approximate_limit.start()].strip()
|
text = text[:approximate_limit.start()].strip()
|
||||||
natural_limit = re.search(
|
natural_limit = re.search(
|
||||||
r"(?:[,.;?]|[—–-]|\s+but\s+|\s+)\s*(?:"
|
r"(?:[,.;?]|[—–-]|(?<!\s)\s++but\s++|(?<!\s)\s++)\s*+(?:"
|
||||||
r"(?:i\s+)?only\s+(?:need|want|show(?:\s+me)?)?\s*(?:(?:the\s+)?first\s+)?"
|
r"(?:i\s++)?only\s++(?:need|want|show(?:\s++me)?)?\s*+(?:(?:the\s++)?first\s++)?"
|
||||||
r"|just\s+(?:(?:the\s+)?first\s+)?|(?:show\s+me\s+)?like\s+|no\s+more\s+than\s+"
|
r"|just\s++(?:(?:the\s++)?first\s++)?|(?:show\s++me\s++)?like\s++|no\s++more\s++than\s++"
|
||||||
r"|cap(?:\s+(?:it|them|the\s+(?:answer|list)))?\s+at\s+"
|
r"|cap(?:\s++(?:it|them|the\s++(?:answer|list)))?\s++at\s++"
|
||||||
r"|(?:maybe\s+)?(?:first|same)\s+)"
|
r"|(?:maybe\s++)?(?:first|same)\s++)"
|
||||||
r"(" + _READ_COUNT + r")"
|
r"(" + _READ_COUNT + r")"
|
||||||
r"(?:\s+(?:short\s+)?(?:titles?|items?|results?|entries?|names?|ones?|bits?|things?))?"
|
r"(?:\s+(?:short\s+)?(?:titles?|items?|results?|entries?|names?|ones?|bits?|things?))?"
|
||||||
r"(?:\s*(?:and|\+)\s+(?:their\s+)?(?:status(?:es)?|states?))?"
|
r"(?:\s*(?:and|\+)\s+(?:their\s+)?(?:status(?:es)?|states?))?"
|
||||||
@@ -2511,7 +2514,7 @@ def _read_request_and_limit(message: str) -> tuple[str, int | None]:
|
|||||||
maximum = int(raw) if raw.isdecimal() else _READ_COUNT_WORDS[raw.lower()]
|
maximum = int(raw) if raw.isdecimal() else _READ_COUNT_WORDS[raw.lower()]
|
||||||
text = text[:compact_limit.start()].strip()
|
text = text[:compact_limit.start()].strip()
|
||||||
conversational_limit = re.search(
|
conversational_limit = re.search(
|
||||||
r"(?:[,.;?]\s*|\s+)(?:maybe\s+)?(?:keep\s+it\s+to\s+|stick\s+to\s+|(?:first|top)\s+)"
|
r"(?:[,.;?]\s*|(?<!\s)\s++)(?:maybe\s+)?(?:keep\s+it\s+to\s+|stick\s+to\s+|(?:first|top)\s+)"
|
||||||
r"(" + _READ_COUNT + r")(?:\s+(?:short\s+)?(?:titles?|items?|results?|entries?|names?|ones?))?"
|
r"(" + _READ_COUNT + r")(?:\s+(?:short\s+)?(?:titles?|items?|results?|entries?|names?|ones?))?"
|
||||||
r"[.!?]*\s*$",
|
r"[.!?]*\s*$",
|
||||||
text, re.I,
|
text, re.I,
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
"""Closure regressions for production CodeQL alerts #1110 through #1129."""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import itertools
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.agent_loop import (
|
||||||
|
_contextual_email_subject,
|
||||||
|
_looks_like_agent_reasoning_preamble,
|
||||||
|
_payload_fields,
|
||||||
|
_pipeline_request_parts,
|
||||||
|
)
|
||||||
|
from src.text_scanning import iter_prefixed_token_matches
|
||||||
|
from src.tool_parsing import _iter_gemma_fallback_items, _parse_gemma_tool_call
|
||||||
|
|
||||||
|
|
||||||
|
PAYLOADS = {
|
||||||
|
"note_update": r'''\s+(.+?)\s+so\s+its\s+content\s+is\s+['"]([^'"]+)['"]''',
|
||||||
|
"email_mutation": r"\s+(?:all|every|the)?\s*(?:my\s+)?(.+?)\s+(?:emails?|mail|messages?)\b",
|
||||||
|
"checklist": r"\s+(.+?)\s+with\s+(.+?)$",
|
||||||
|
"tag": r"\s+(?:the\s+)?(.+?)\s+tag\s+to\s+#?([a-z][a-z0-9_-]{1,30})\b",
|
||||||
|
"change": r"\s+(.+?)\s+to\s+(.+?)(?=\s+(?:in|and|then|before)\b|[.;]|$)",
|
||||||
|
"replace": r"\s+(.+?)\s+with\s+(.+?)(?=\s+(?:in|and|then|before)\b|[.;]|$)",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("grammar,pattern", PAYLOADS.items())
|
||||||
|
def test_payload_fields_preserve_whitespace_and_optional_leader_priority(grammar, pattern):
|
||||||
|
legacy = re.compile(pattern, re.I)
|
||||||
|
spaces = [" ", "\t", "\n", "\r", " ", "\t\n\t", "\n\n\n", "\u2003"]
|
||||||
|
words = ["", "x", "all", "my", "the", "with", "to"]
|
||||||
|
ends = [" emails", " checklist", " so its content is 'v'", " with y", " to y", " tag to ab"]
|
||||||
|
cases = [a + b + a + c for a, b, c in itertools.product(spaces, words, ends)]
|
||||||
|
rng = random.Random(1117)
|
||||||
|
tokens = spaces + words + ends + ["in", "and", ".", ";", "'v'"]
|
||||||
|
cases += ["".join(rng.choices(tokens, k=15)) for _ in range(5000)]
|
||||||
|
for text in cases:
|
||||||
|
match = legacy.match(text)
|
||||||
|
assert _payload_fields(text, grammar) == (match.groups() if match else None), text
|
||||||
|
|
||||||
|
|
||||||
|
def test_pipeline_fields_preserve_empty_looking_captures():
|
||||||
|
pipeline = re.compile(
|
||||||
|
r"\bpipeline\s+using\s+([^\s,]+)\s+to\s+(.+?),\s*then\s+([^\s,]+)\s+to\s+(.+?)(?:[.!?]\s*)?$", re.I,
|
||||||
|
)
|
||||||
|
for space, first, second in itertools.product(
|
||||||
|
[" ", " ", "\t\n\t", "\n", "\r"], ["x", "", ",then n to", "!"], ["x", "", "!", "! ", "X\n"],
|
||||||
|
):
|
||||||
|
text = "pipeline using m to" + space + first + ",then n to" + space + second
|
||||||
|
match = pipeline.search(text)
|
||||||
|
assert _pipeline_request_parts(text) == (match.groups() if match else None), text
|
||||||
|
|
||||||
|
|
||||||
|
def test_gemma_fallback_preserves_permissive_values_and_multiline_failures():
|
||||||
|
legacy = re.compile(r'''(\w+)\s*:\s*["']?(.*?)["']?(?=\s*,\s*\w+\s*:|\s*\})''')
|
||||||
|
cases = ["{query: hello, x: 'world'}", "{query:'a,b:c'}", "a:\n\t}", "0:\t\tX", "a:'', b: }", "a: multiline\nfails, b: ok}"]
|
||||||
|
rng = random.Random(1128)
|
||||||
|
tokens = ["a", "b", "0", ":", ",", "}", "'", '"', " ", "\t", "\n", "\u2003"]
|
||||||
|
cases += ["".join(rng.choices(tokens, k=40)) for _ in range(15000)]
|
||||||
|
for text in cases:
|
||||||
|
expected = [(match.group(1), match.group(2).strip()) for match in legacy.finditer(text)]
|
||||||
|
assert list(_iter_gemma_fallback_items(text)) == expected, text
|
||||||
|
block = _parse_gemma_tool_call("web_search", "{query: hello world}")
|
||||||
|
assert block.tool_type == "web_search"
|
||||||
|
assert block.content == "hello world"
|
||||||
|
|
||||||
|
|
||||||
|
def test_subject_capture_preserves_optional_formatting_and_whitespace():
|
||||||
|
legacy = re.compile(r'''\bsubject\s*(?:\*\*)?\s*:?\s*(?:"|\*")?(.+?)(?:"|\n|$)''', re.I)
|
||||||
|
rng = random.Random(1123)
|
||||||
|
tokens = ["subject", "subjectx", "**", ":", '*"', '"', "a", " ", "\t", "\n", "\r"]
|
||||||
|
cases = ["Subject:", "Subject:\t", "Subject**:\n\n", "Subject \t\nTitle", "Subject\n\n", 'subject""']
|
||||||
|
cases += ["".join(rng.choices(tokens, k=16)) for _ in range(10000)]
|
||||||
|
for text in cases:
|
||||||
|
match = legacy.search(text)
|
||||||
|
assert _contextual_email_subject(text) == (match.group(1) if match else None), text
|
||||||
|
|
||||||
|
|
||||||
|
def _regex_literals(path):
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
tree = ast.parse((root / path).read_text())
|
||||||
|
return [node.value for node in ast.walk(tree) if isinstance(node, ast.Constant) and isinstance(node.value, str)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_terminal_read_only_removal_preserves_comma_and_whitespace_behavior():
|
||||||
|
legacy = (
|
||||||
|
r"[.!?]\s*read[- ]only(?:\s+(?:please|pls|plz))?\s*,?\s*"
|
||||||
|
r"(?:do\s+not|don['’]?t|dont)\s+(?:change|edit|modify)\s+"
|
||||||
|
r"(?:or\s+send\s+)?anything[.!?]*\s*$"
|
||||||
|
)
|
||||||
|
current = next(p for p in _regex_literals("src/turn_contract.py") if p.startswith(r"[.!?]\s*+read[- ]only"))
|
||||||
|
for space, optional, comma, ending in itertools.product(
|
||||||
|
["", " ", "\t", "\n\t"], ["", " please", " pls"], ["", ","], ["", ".", "!?", "X"],
|
||||||
|
):
|
||||||
|
text = "open calendar!read only" + optional + space + comma + space + "do not edit anything" + ending
|
||||||
|
assert re.sub(current, "", text, flags=re.I) == re.sub(legacy, "", text, flags=re.I), text
|
||||||
|
|
||||||
|
|
||||||
|
def test_bounded_session_listing_and_reasoning_tail_preserve_regex_behavior():
|
||||||
|
literals = _regex_literals("src/agent_loop.py")
|
||||||
|
listing = next(p for p in literals if p.startswith(r"\b(?:list|show|view)\b.{0,30}") and "my" in p)
|
||||||
|
old_listing = listing.replace(r"\s++", r"\s+").replace(r"\s*+", r"\s*")
|
||||||
|
preamble = next(p for p in literals if p.startswith(r"(?:but\s+)?(?:now\s+)?") and "[^.!?]*+" in p)
|
||||||
|
old_preamble = preamble.replace("[^.!?]*+", "[^.!?]*")
|
||||||
|
rng = random.Random(1119)
|
||||||
|
tokens = ["list", "my", "recent", "chat", "now let me verify", " ", "\t", "\n", ".", "!", "x"]
|
||||||
|
cases = ["".join(rng.choices(tokens, k=15)) for _ in range(3000)]
|
||||||
|
for text in cases:
|
||||||
|
assert bool(re.search(listing, text, re.I)) == bool(re.search(old_listing, text, re.I)), text
|
||||||
|
assert bool(re.match(preamble, text)) == bool(re.match(old_preamble, text)), text
|
||||||
|
assert not _looks_like_agent_reasoning_preamble("Answer. now let me verify\t\t!!")
|
||||||
|
|
||||||
|
|
||||||
|
def test_calendar_digit_guard_prevents_suffix_retries_without_changing_units():
|
||||||
|
patterns = [p for p in _regex_literals("src/tools/calendar.py") if p.startswith(r"(?<!\d)(\d+)\s*")]
|
||||||
|
assert len(patterns) == 4
|
||||||
|
for pattern in patterns:
|
||||||
|
legacy = pattern.replace(r"(?<!\d)", "")
|
||||||
|
for value in ("10 minutes", "1hr30m", "9 hours", "12\tmin", "0" * 2000 + "X"):
|
||||||
|
match = re.search(pattern, value)
|
||||||
|
old = re.search(legacy, value)
|
||||||
|
assert (match.groups() if match else None) == (old.groups() if old else None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_prefixed_token_helper_tests_only_one_failed_prefix_per_token():
|
||||||
|
class Counted:
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = 0
|
||||||
|
self.regex = re.compile(r"/workspace/[^\s]+\.txt\b")
|
||||||
|
def match(self, text, pos):
|
||||||
|
self.calls += 1
|
||||||
|
return self.regex.match(text, pos)
|
||||||
|
parser = Counted()
|
||||||
|
flood = "/workspace/" * 10000 + "X"
|
||||||
|
assert list(iter_prefixed_token_matches(flood, re.compile(r"/workspace/"), parser, re.compile(r"\S*"))) == []
|
||||||
|
assert parser.calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("program", [
|
||||||
|
"from src.agent_loop import _looks_like_agent_reasoning_preamble as f; assert not f('Answer. now let me verify ' + '\\t'*100000 + '!!')",
|
||||||
|
"from src.agent_loop import _payload_fields; [ _payload_fields('\\t'*100000+'X', g) for g in ('note_update','email_mutation','checklist','tag','change','replace','remaining_checklist') ]",
|
||||||
|
"from src.agent_loop import _payload_fields; assert _payload_fields(' a' + ' to x'*30000 + '\\nX', 'change') is None",
|
||||||
|
"from src.agent_loop import _pipeline_request_parts as f; assert f('pipeline using m to'+'\\t'*100000+'X') is None",
|
||||||
|
"from src.agent_loop import _parse_qwen_explicit_session_find as f; f('list my'+'\\t'*100000+'X chats')",
|
||||||
|
"from src.agent_loop import _parse_simple_notes_tool_request as f; f('create note saying'+'\\t'*100000+'X\\nY')",
|
||||||
|
"from src.agent_loop import _contextual_email_subject as f; assert f('subject'+'\\n'*100000) is None",
|
||||||
|
"from src.agent_loop import _looks_like_ody_qwen_leaked_tool_text as f; assert not f('<'+'\\t'*100000+'X')",
|
||||||
|
"from src.tool_parsing import _iter_gemma_fallback_items as f; assert list(f('0'*100000+'X')) == []; assert list(f('0:'+'\\t'*100000+'X')) == []",
|
||||||
|
"from src.tool_parsing import _iter_gemma_fallback_items as f; assert len(list(f('a:x,'*50000+'b:y}'))) == 50001",
|
||||||
|
"from src.turn_contract import _read_request_and_limit as f; f('open calendar!read only'+'\\t'*100000+'X')",
|
||||||
|
])
|
||||||
|
def test_induced_defects_complete_under_process_deadline(program):
|
||||||
|
subprocess.run([sys.executable, "-c", program], check=True, timeout=8, capture_output=True, text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_only_reproducer_related_limit_prefixes_preserve_matches():
|
||||||
|
literals = _regex_literals("src/turn_contract.py")
|
||||||
|
natural = next(p for p in literals if p.startswith(r"(?:[,.;?]|[—–-]|(?<!\s)\s++but"))
|
||||||
|
conversational = next(p for p in literals if p.startswith(r"(?:[,.;?]\s*|(?<!\s)\s++)"))
|
||||||
|
rng = random.Random(1129)
|
||||||
|
tokens = ["open calendar", "read only", " ", "\t", "\n", ",", "but", "only", "need", "just", "first", "3", "titles", "and", "status", "maybe", "keep it to", "top", "short"]
|
||||||
|
cases = ["open calendar only 3", "open calendar but only need 3 titles", "open calendar keep it to 3", "x only\t\tX"]
|
||||||
|
cases += ["".join(rng.choices(tokens, k=15)) for _ in range(5000)]
|
||||||
|
# The full pattern includes the count expression concatenated between
|
||||||
|
# literals. Obtain it from the real production search call, rather than
|
||||||
|
# testing only the first source-code fragment.
|
||||||
|
from src import turn_contract
|
||||||
|
counts = turn_contract._READ_COUNT
|
||||||
|
natural_tail = (
|
||||||
|
r"(?:\s+(?:short\s+)?(?:titles?|items?|results?|entries?|names?|ones?|bits?|things?))?"
|
||||||
|
r"(?:\s*(?:and|\+)\s+(?:their\s+)?(?:status(?:es)?|states?))?"
|
||||||
|
r"(?:\s*(?:\+|and)\s+(?:whether|if)\s+[^.;\n]+)?[.!?]*\s*$"
|
||||||
|
)
|
||||||
|
conversational_tail = r")(?:\s+(?:short\s+)?(?:titles?|items?|results?|entries?|names?|ones?))?[.!?]*\s*$"
|
||||||
|
patterns = [natural + counts + r")" + natural_tail, conversational + counts + conversational_tail]
|
||||||
|
for current in patterns:
|
||||||
|
legacy = current.replace(r"(?<!\s)", "").replace(r"\s++", r"\s+").replace(r"\s*+", r"\s*")
|
||||||
|
for text in cases:
|
||||||
|
new = re.search(current, text, re.I)
|
||||||
|
old = re.search(legacy, text, re.I)
|
||||||
|
assert ((new.span(), new.groups()) if new else None) == ((old.span(), old.groups()) if old else None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("program", [
|
||||||
|
"from routes.chat_helpers import _normalize_thinking as f; assert f('a'+'\\n\\n'*100000+'X') == 'a'+'\\n\\n'*100000+'X'",
|
||||||
|
"from src.clean_agent_preview import unbound_lookup_reference as f; assert not f('\\t'*100000+'X', []); assert f('\\t'*100000+'look it up', [])",
|
||||||
|
"from src.agent_loop import _is_terse_link_request as f; assert not f('links for'+'\\t'*100000+'X')",
|
||||||
|
"from src.tool_parsing import _iter_qwen_python_args as f; assert list(f('a='+'\\t'*100000+',')) == [('a', '\\t')]",
|
||||||
|
])
|
||||||
|
def test_unchanged_linear_alert_shapes_complete_under_process_deadline(program):
|
||||||
|
subprocess.run([sys.executable, "-c", program], check=True, timeout=8, capture_output=True, text=True)
|
||||||
@@ -65,15 +65,15 @@ The source tree reads **117** `ODYSSEUS_*` variables: 81 an operator may want to
|
|||||||
| `ODYSSEUS_LOCAL_MODEL_GATE` | `'true'` | `src/llm_core.py:95` | On by default. Set 0, false, no or off to drop the gate that checks a local endpoint before routing a request to it. |
|
| `ODYSSEUS_LOCAL_MODEL_GATE` | `'true'` | `src/llm_core.py:95` | On by default. Set 0, false, no or off to drop the gate that checks a local endpoint before routing a request to it. |
|
||||||
| `ODYSSEUS_MISTRAL_REASONING_EFFORT` | `'high'` | `src/llm_core.py:1723` | Reasoning effort sent to Mistral thinking-capable models. The API accepts high, medium, low and none. |
|
| `ODYSSEUS_MISTRAL_REASONING_EFFORT` | `'high'` | `src/llm_core.py:1723` | Reasoning effort sent to Mistral thinking-capable models. The API accepts high, medium, low and none. |
|
||||||
| `ODYSSEUS_MLX_IMAGE_VLM_MODEL` | *unset* | `scripts/mlx_image_server.py:299` | Vision-language model id for the MLX image server script. Required unless `--vlm-model` is passed on the command line. |
|
| `ODYSSEUS_MLX_IMAGE_VLM_MODEL` | *unset* | `scripts/mlx_image_server.py:299` | Vision-language model id for the MLX image server script. Required unless `--vlm-model` is passed on the command line. |
|
||||||
| `ODYSSEUS_QWEN_ROUTE_THINKING` | `'auto'` | `src/agent_loop.py:180` | Thinking policy for the Qwen routing step. An unrecognized value falls back to `auto`. |
|
| `ODYSSEUS_QWEN_ROUTE_THINKING` | `'auto'` | `src/agent_loop.py:182` | Thinking policy for the Qwen routing step. An unrecognized value falls back to `auto`. |
|
||||||
|
|
||||||
### Agent loop and tool execution
|
### Agent loop and tool execution
|
||||||
|
|
||||||
| Variable | Default | Read in | What it does |
|
| Variable | Default | Read in | What it does |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `ODYSSEUS_DISABLE_MCP` | `''` | `src/builtin_mcp.py:89` | Truthy disables MCP entirely, as an escape hatch for compatibility problems with a server. |
|
| `ODYSSEUS_DISABLE_MCP` | `''` | `src/builtin_mcp.py:89` | Truthy disables MCP entirely, as an escape hatch for compatibility problems with a server. |
|
||||||
| `ODYSSEUS_MAX_VISUAL_EVIDENCE_FRAMES` | `'3'` | `src/agent_loop.py:15867` | How many video frames one tool result may contribute. Clamped to 1-8. |
|
| `ODYSSEUS_MAX_VISUAL_EVIDENCE_FRAMES` | `'3'` | `src/agent_loop.py:15986` | How many video frames one tool result may contribute. Clamped to 1-8. |
|
||||||
| `ODYSSEUS_MAX_VISUAL_EVIDENCE_IMAGES` | `'1'` | `src/agent_loop.py:15835` | How many images one tool result may contribute to the model turn. Clamped to 1-8. |
|
| `ODYSSEUS_MAX_VISUAL_EVIDENCE_IMAGES` | `'1'` | `src/agent_loop.py:15954` | How many images one tool result may contribute to the model turn. Clamped to 1-8. |
|
||||||
| `ODYSSEUS_MCP_ALLOWED_COMMANDS` | `''` | `src/agent_tools/admin_tools.py:140` | Security-relevant. Comma-separated allowlist of MCP launcher basenames the agent may start. Empty by default, and the deny list still wins. |
|
| `ODYSSEUS_MCP_ALLOWED_COMMANDS` | `''` | `src/agent_tools/admin_tools.py:140` | Security-relevant. Comma-separated allowlist of MCP launcher basenames the agent may start. Empty by default, and the deny list still wins. |
|
||||||
| `ODYSSEUS_PYTHON_TOOL_SITE_PACKAGES` | `''` | `src/agent_runtime/process_resources.py:59` (+2 more) | Security-relevant. Absolute package roots, separated by the platform path separator, exposed to the sandboxed Python tool. Empty exposes none. |
|
| `ODYSSEUS_PYTHON_TOOL_SITE_PACKAGES` | `''` | `src/agent_runtime/process_resources.py:59` (+2 more) | Security-relevant. Absolute package roots, separated by the platform path separator, exposed to the sandboxed Python tool. Empty exposes none. |
|
||||||
| `ODYSSEUS_SCRIPT_HOST` | `'localhost'` | `src/builtin_actions.py:925` | Default host for the run-script action. `localhost`, `127.0.0.1`, `local` and empty run locally; any other value runs over SSH. |
|
| `ODYSSEUS_SCRIPT_HOST` | `'localhost'` | `src/builtin_actions.py:925` | Default host for the run-script action. `localhost`, `127.0.0.1`, `local` and empty run locally; any other value runs over SSH. |
|
||||||
@@ -197,8 +197,8 @@ Listed for completeness. Setting one of these on a real install is either a no-o
|
|||||||
|
|
||||||
| Variable | Default | Read in | What it does |
|
| Variable | Default | Read in | What it does |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `ODYSSEUS_CAPTURE_MODEL_REQUESTS` | `''` | `src/agent_loop.py:4036` | Truthy writes model-request snapshots for local debugging. The marker file `/tmp/odysseus_capture_model_requests` enables the same thing. |
|
| `ODYSSEUS_CAPTURE_MODEL_REQUESTS` | `''` | `src/agent_loop.py:4131` | Truthy writes model-request snapshots for local debugging. The marker file `/tmp/odysseus_capture_model_requests` enables the same thing. |
|
||||||
| `ODYSSEUS_EXPOSE_RAW_BROWSER_MCP` | `''` | `src/agent_loop.py:4241` | Truthy stops hiding the raw Playwright MCP tools from agent prompts when the private-browser tool is available. |
|
| `ODYSSEUS_EXPOSE_RAW_BROWSER_MCP` | `''` | `src/agent_loop.py:4336` | Truthy stops hiding the raw Playwright MCP tools from agent prompts when the private-browser tool is available. |
|
||||||
| `ODYSSEUS_TOOL_CONTRACT_ROOT` | `str(Path(__file__).resolve().parents[1] / 'scripts')` | `src/clean_agent_preview.py:2324` (+1 more) | Directory holding the tool-contract scripts the clean-agent preview loads. The default resolves to the bundled scripts directory relative to the installed/source tree. |
|
| `ODYSSEUS_TOOL_CONTRACT_ROOT` | `str(Path(__file__).resolve().parents[1] / 'scripts')` | `src/clean_agent_preview.py:2324` (+1 more) | Directory holding the tool-contract scripts the clean-agent preview loads. The default resolves to the bundled scripts directory relative to the installed/source tree. |
|
||||||
|
|
||||||
### Email
|
### Email
|
||||||
@@ -229,7 +229,7 @@ Listed for completeness. Setting one of these on a real install is either a no-o
|
|||||||
| `ODYSSEUS_QA_TEACHER_TIMEOUT` | `'120'` | `scripts/odysseus_conversation_qa.py:370` | Timeout in seconds for that call. Clamped to 15-120. |
|
| `ODYSSEUS_QA_TEACHER_TIMEOUT` | `'120'` | `scripts/odysseus_conversation_qa.py:370` | Timeout in seconds for that call. Clamped to 15-120. |
|
||||||
| `ODYSSEUS_RUNTIME_REVISION` | `''` | `routes/chat_helpers.py:200` (+1 more) | Revision string stamped into each captured SFT trace record, so a trace can be tied back to the build that produced it. |
|
| `ODYSSEUS_RUNTIME_REVISION` | `''` | `routes/chat_helpers.py:200` (+1 more) | Revision string stamped into each captured SFT trace record, so a trace can be tied back to the build that produced it. |
|
||||||
| `ODYSSEUS_SFT_DIR` | *unset* | `scripts/run_odysseus_search_teacher_pipeline.py:19` (+1 more) | Base directory containing SFT training datasets for the search teacher pipeline. |
|
| `ODYSSEUS_SFT_DIR` | *unset* | `scripts/run_odysseus_search_teacher_pipeline.py:19` (+1 more) | Base directory containing SFT training datasets for the search teacher pipeline. |
|
||||||
| `ODYSSEUS_SFT_DISABLE_WORKSPACE_TOOLS` | `'1'` | `src/agent_loop.py:7753` | On by default. Keeps synthetic personal-assistant fixtures out of workspace mode; set 0, false, no or off to let them through. |
|
| `ODYSSEUS_SFT_DISABLE_WORKSPACE_TOOLS` | `'1'` | `src/agent_loop.py:7844` | On by default. Keeps synthetic personal-assistant fixtures out of workspace mode; set 0, false, no or off to let them through. |
|
||||||
| `ODYSSEUS_SFT_FORCE_UTC_TIMEZONE` | `'0'` | `routes/chat_routes.py:2145` | Truthy forces `sft_` accounts to UTC for deterministic batch generation. Interactive accounts still follow the browser timezone. |
|
| `ODYSSEUS_SFT_FORCE_UTC_TIMEZONE` | `'0'` | `routes/chat_routes.py:2145` | Truthy forces `sft_` accounts to UTC for deterministic batch generation. Interactive accounts still follow the browser timezone. |
|
||||||
| `ODYSSEUS_SFT_TRACE_CAPTURE` | `'1'` | `routes/chat_helpers.py:163` (+1 more) | On by default, but only for owners whose name starts with `sft_`. Set 0, false, no or off to stop writing training traces. |
|
| `ODYSSEUS_SFT_TRACE_CAPTURE` | `'1'` | `routes/chat_helpers.py:163` (+1 more) | On by default, but only for owners whose name starts with `sft_`. Set 0, false, no or off to stop writing training traces. |
|
||||||
| `ODYSSEUS_SFT_TRACE_DIR` | *unset* | `routes/chat_helpers.py:197` (+2 more) | Directory the SFT trace JSONL files are written to. Defaults to `sft_traces` under the data directory. |
|
| `ODYSSEUS_SFT_TRACE_DIR` | *unset* | `routes/chat_helpers.py:197` (+2 more) | Directory the SFT trace JSONL files are written to. Defaults to `sft_traces` under the data directory. |
|
||||||
|
|||||||
Reference in New Issue
Block a user