Skip to content

Commit a83df66

Browse files
fix(agent): pop agent_stack on failed handoff; guard recent_turns<=0 (#60)
Three handoff-path fixes, verified against the merged-onto-new-dev state. - executor.py: a failed handoff now pops the target whenever it is on top of agent_stack (it was pushed only after early depth/cycle/registry checks passed), regardless of return_to_parent. Prevents a stuck target from tripping false cycle detection on retry and inflating agents_used. - handoff/history.py: _find_turn_boundary returns len(messages) for recent_turns<=0 so the recent slice is empty (RECENT_N/HYBRID). Previously user_indices[-0] returned nearly the full history. - utils/sanitization.py: allow '|' in user_id/conversation_id (Auth0-style 'sub' claims). ':' remains the only excluded key delimiter; nothing splits on '|', so no scope-bleed. Adds adversarial tests for handoff depth/cycle/stack-cleanup/summarization and a whitespace user_id journey test. Co-Authored-By: claude-flow <ruv@ruv.net>
1 parent 4612344 commit a83df66

7 files changed

Lines changed: 1049 additions & 7 deletions

File tree

src/continuum/agent/execution/executor.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -639,10 +639,14 @@ async def _execute_loop_impl(
639639
messages=messages,
640640
)
641641
else:
642-
# Handoff failed — clean up stack if return_to_parent
643-
# (push_agent already ran in execute_handoff)
644-
handoff_config = agent.get_handoff(target)
645-
if handoff_config and handoff_config.return_to_parent:
642+
# Handoff failed. execute_handoff pushes the target
643+
# only AFTER its early depth/cycle/registry checks
644+
# pass, so the target is on the stack only in
645+
# post-push failures — where it sits on top. Pop it
646+
# whenever it's on top, regardless of return_to_parent,
647+
# so a failed hop never leaves the target stuck (which
648+
# would trip false cycle detection on a retry).
649+
if run_state.agent_stack and run_state.agent_stack[-1] == target:
646650
run_state.pop_agent()
647651
run_state.current_agent = agent.name
648652
# Add error as tool result so the LLM can react

src/continuum/agent/handoff/history.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ def _find_turn_boundary(messages: list[dict[str, Any]], n_turns: int) -> int:
5858
A turn starts at each user message. All messages within a turn
5959
(tool calls, tool results, assistant responses) are included.
6060
"""
61+
# n_turns <= 0 means "keep no recent turns". Return an index past the end
62+
# so messages[boundary:] == []. Without this guard, n_turns == 0 hits
63+
# user_indices[-0] (== user_indices[0]) and returns almost the entire
64+
# history instead of nothing.
65+
if n_turns <= 0:
66+
return len(messages)
6167
user_indices = [i for i, m in enumerate(messages) if m.get("role") == "user"]
6268
if len(user_indices) <= n_turns:
6369
return 0

src/continuum/utils/sanitization.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,13 @@ def sanitize_message_content(message: dict[str, Any]) -> dict[str, Any]:
6060
return message
6161

6262

63-
# Allowlist for user_id and conversation_id: alphanumeric, hyphen, underscore, dot, @
63+
# Allowlist for user_id and conversation_id: alphanumeric, hyphen, underscore, dot, @, pipe.
6464
# Colons are explicitly excluded — they are key delimiters in Redis session keys
6565
# ("c:{conversation_id}:u:{user_id}") and cart_session_id (f"{user_id}:{conversation_id}").
6666
# A colon in either value would silently collapse into another user's scoping key.
67-
_ID_ALLOWED_RE = re.compile(r"[^a-zA-Z0-9\-_.@]")
67+
# Pipe ("|") IS allowed: Auth0-style JWT 'sub' claims look like "auth0|64abc..." and
68+
# the pipe is not a key delimiter here, so it is safe (PR #55 review follow-up).
69+
_ID_ALLOWED_RE = re.compile(r"[^a-zA-Z0-9\-_.@|]")
6870
_ID_MAX_LENGTH = 128
6971

7072

@@ -112,7 +114,7 @@ def _validate_id(value: str | None, field: str) -> str | None:
112114
field,
113115
value,
114116
f"contains disallowed character {bad.group()!r}; "
115-
"allowed characters are letters, digits, and - _ . @",
117+
"allowed characters are letters, digits, and - _ . @ |",
116118
)
117119
return cleaned
118120

0 commit comments

Comments
 (0)