mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-11-10 12:51:43 -10:00
fix(backend/copilot): a delegated Expert chat starts on the mode its parent chat's gate enforces (#15643)
A delegated or handed-off Expert chat now starts on the approval mode its parent chat's gate actually enforces, so a Slack, Discord, Teams or Telegram chat can no longer open an Expert chat that runs looser than itself. Resolves #15642 Follow-up to #15115 ### Why / What / How **Why:** a chat from a linked platform (Slack, Discord, Teams, Telegram) always runs on Auto, because `gate.resolve_mode` ignores the mode stored on it. `delegate_to_expert` and `handoff_to_expert` gave the new Expert chat `child_autopilot_mode(session.metadata.autopilot_mode, …)`, which is the stored mode. A linked chat whose stored mode was Unsupervised therefore ran on Auto, while an Expert it delegated to or handed work to ran Unsupervised. Outward actions that ask in the parent ran without asking in the child. **What:** the new chat starts on the mode the parent's gate enforces, tightened by the target Expert's own default where that is stricter. Web chats are unchanged, since there the enforced mode is the stored one. **How:** `child_autopilot_mode` takes the parent session and calls `resolve_mode` itself, so no caller can pass it a stored mode. It moves from `gate/policy.py` to `gate/__init__.py`, next to `resolve_mode`. It always returns a mode, so the `or DEFAULT_MODE` at both call sites goes. The other reads of the stored mode stay: - `gate/review.py` and `gate/reads.py` copy it onto an approval card's payload, where the web approval queue shows it as the card's mode line. That is display only; nothing decides on it. - `chat/routes.py` and `copilot/model.py` read and write it for persistence. @Abhi1992002: in #14995, `delegation_terms` (`copilot/tools/delegation_policy.py`) also takes an Expert chat's hand-off mode from `session.metadata.autopilot_mode`. It is worth the same look. ### Changes 🏗️ - `copilot/gate/__init__.py`: `child_autopilot_mode(parent: ChatSession, expert_default)`, reading `resolve_mode`. - `copilot/gate/policy.py`: the stored-mode version is removed; `strictest_mode` stays. - `copilot/tools/delegate_to_expert.py`, `copilot/tools/handoff_to_expert.py`: pass the parent session. - Tests: - The helper's tests move from `policy_test.py` to `gate_test.py` and take sessions, plus a case for each linked platform. - Delegation and handoff each get a linked parent stored as Unsupervised, run against no Expert default, an Unsupervised default and an Ask First default. ### Verified The new delegation and handoff tests fail on `dev`'s code (4 failed: both tools, with no Expert default and with an Unsupervised default) and pass here. Against the new helper, three mutations each turn their own tests red: - reading the stored mode again (8 failed); - dropping the Expert default (9 failed); - always starting on Auto (6 failed, the web cases). I also ran the following, all green: - the gate directory, the delegation and handoff tests and the tests that import them, and `util/architecture_test.py`: 1,168 passed; - `blocks/test/test_block.py`: 3,696 passed; - the gate's database tests, the trial activation tests and `bot/card_buttons_db_test.py`, on a throwaway Postgres: 75 passed. ### Agents and large language models used Claude Code with Claude Opus 5.5. ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] A linked chat stored as Unsupervised gives a delegated and a handed-off Expert chat Auto; a stricter Expert default still wins - [x] Web chats keep the parent's mode and the Expert-default cap, as before - [x] Each new test fails under the mutation it guards 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 (Claude Code) <[email protected]>
This commit is contained in:
1 parent
ec951d81c6
commit
6d793e53d8
9 files changed
+81
-38
No files matched your search
@@ -38,6 +38,7 @@ from .policy import (
|
||||
Verdict,
|
||||
effect_for,
|
||||
estimate_for,
|
||||
strictest_mode,
|
||||
verdict_for_effect,
|
||||
)
|
||||
from .subject import Subject
|
||||
@@ -124,6 +125,16 @@ async def active_mode(
|
||||
return resolve_mode(session) if await gate_active(user_id, session) else None
|
||||
|
||||
|
||||
def child_autopilot_mode(
|
||||
parent: ChatSession, expert_default: AutopilotMode | None
|
||||
) -> AutopilotMode:
|
||||
"""The mode a delegated or handed-off thread starts on: what the parent's
|
||||
gate enforces, not what the parent stored, tightened by the expert's own
|
||||
default where that is stricter."""
|
||||
mode = resolve_mode(parent)
|
||||
return mode if expert_default is None else strictest_mode(mode, expert_default)
|
||||
|
||||
|
||||
async def check_action(
|
||||
tool_name: str,
|
||||
args: dict[str, Any],
|
||||
@@ -354,6 +365,7 @@ __all__ = [
|
||||
"LINKED_CHAT_PLATFORMS",
|
||||
"active_mode",
|
||||
"check_action",
|
||||
"child_autopilot_mode",
|
||||
"gate_active",
|
||||
"refusal_message",
|
||||
"resolve_mode",
|
||||
|
||||
@@ -13,10 +13,12 @@ import pytest
|
||||
from prisma.enums import ReviewStatus
|
||||
|
||||
from backend.copilot.gate import (
|
||||
LINKED_CHAT_PLATFORMS,
|
||||
RUN_FILES_KEY,
|
||||
active_mode,
|
||||
chat_rules,
|
||||
check_action,
|
||||
child_autopilot_mode,
|
||||
gate_active,
|
||||
held,
|
||||
)
|
||||
@@ -140,6 +142,28 @@ async def test_the_default_mode_is_auto(gate_on):
|
||||
assert await active_mode("u", _session()) == "auto"
|
||||
|
||||
|
||||
def test_a_delegated_thread_follows_its_parent_when_the_expert_has_no_default():
|
||||
assert child_autopilot_mode(_session("ask_first"), None) == "ask_first"
|
||||
assert child_autopilot_mode(_session("unsupervised"), None) == "unsupervised"
|
||||
assert child_autopilot_mode(_session(), None) == "auto"
|
||||
|
||||
|
||||
def test_a_delegated_thread_is_never_looser_than_its_parent():
|
||||
assert child_autopilot_mode(_session("ask_first"), "unsupervised") == "ask_first"
|
||||
# A parent on the platform default cannot hand out an unsupervised thread.
|
||||
assert child_autopilot_mode(_session(), "unsupervised") == "auto"
|
||||
assert child_autopilot_mode(_session("unsupervised"), "ask_first") == "ask_first"
|
||||
assert child_autopilot_mode(_session("auto"), "auto") == "auto"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform", sorted(LINKED_CHAT_PLATFORMS))
|
||||
def test_a_linked_chat_hands_its_thread_the_mode_its_gate_enforces(platform):
|
||||
"""A linked chat runs on Auto whatever it has stored."""
|
||||
parent = _session("unsupervised", source_platform=platform)
|
||||
assert child_autopilot_mode(parent, None) == "auto"
|
||||
assert child_autopilot_mode(parent, "ask_first") == "ask_first"
|
||||
|
||||
|
||||
async def test_an_approval_is_consulted_before_the_effect(gate_on, clean_session_state):
|
||||
"""Otherwise an approved outward call would park a second card forever."""
|
||||
with (
|
||||
|
||||
@@ -19,20 +19,6 @@ def strictest_mode(*modes: AutopilotMode) -> AutopilotMode:
|
||||
return min(modes, key=_MODES_BY_STRICTNESS.index)
|
||||
|
||||
|
||||
def child_autopilot_mode(
|
||||
parent: AutopilotMode | None, expert_default: AutopilotMode | None
|
||||
) -> AutopilotMode | None:
|
||||
"""The mode a delegated thread starts on.
|
||||
|
||||
An expert with no default follows the chat that delegated to it. One with
|
||||
a default never gets a looser thread than that chat: a parent in
|
||||
``ask_first`` cannot farm work out to an unsupervised teammate.
|
||||
"""
|
||||
if expert_default is None:
|
||||
return parent
|
||||
return strictest_mode(parent or DEFAULT_MODE, expert_default)
|
||||
|
||||
|
||||
class Effect(str, Enum):
|
||||
READ = "read"
|
||||
WORKSPACE = "workspace"
|
||||
|
||||
@@ -7,7 +7,6 @@ from backend.copilot.gate.policy import (
|
||||
MCP_FILE_WRITE_TOOLS,
|
||||
Effect,
|
||||
Verdict,
|
||||
child_autopilot_mode,
|
||||
classified_tools,
|
||||
effect_for,
|
||||
strictest_mode,
|
||||
@@ -81,17 +80,3 @@ def test_the_strictest_mode_is_the_one_that_asks_most():
|
||||
assert strictest_mode("auto", "ask_first") == "ask_first"
|
||||
assert strictest_mode("unsupervised", "auto") == "auto"
|
||||
assert strictest_mode("unsupervised") == "unsupervised"
|
||||
|
||||
|
||||
def test_a_delegated_thread_follows_its_parent_when_the_expert_has_no_default():
|
||||
assert child_autopilot_mode("ask_first", None) == "ask_first"
|
||||
assert child_autopilot_mode("unsupervised", None) == "unsupervised"
|
||||
assert child_autopilot_mode(None, None) is None
|
||||
|
||||
|
||||
def test_a_delegated_thread_is_never_looser_than_its_parent():
|
||||
assert child_autopilot_mode("ask_first", "unsupervised") == "ask_first"
|
||||
# A parent on the platform default cannot hand out an unsupervised thread.
|
||||
assert child_autopilot_mode(None, "unsupervised") == "auto"
|
||||
assert child_autopilot_mode("unsupervised", "ask_first") == "ask_first"
|
||||
assert child_autopilot_mode("auto", "auto") == "auto"
|
||||
@@ -1417,7 +1417,7 @@ async def create_chat_session(
|
||||
is off, like the per-thread override.
|
||||
inherited_autopilot_mode: The mode a delegated thread must start on,
|
||||
already decided by the caller from the parent chat and the
|
||||
target expert (``gate.policy.child_autopilot_mode``). Stored
|
||||
target expert (``gate.child_autopilot_mode``). Stored
|
||||
as-is: it is a constraint rather than a pick, so the flag does
|
||||
not drop it and the expert is not read a second time.
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ from backend.api.features.experts.models import Expert
|
||||
from backend.copilot.active_turns import spawn_limit_message
|
||||
from backend.copilot.budget_signal import build_spawn_state_note
|
||||
from backend.copilot.context import get_current_permissions
|
||||
from backend.copilot.gate.policy import DEFAULT_MODE, child_autopilot_mode
|
||||
from backend.copilot.gate import child_autopilot_mode
|
||||
from backend.copilot.model import (
|
||||
ChatSession,
|
||||
child_session_origin,
|
||||
@@ -309,9 +309,8 @@ class DelegateToExpertTool(BaseTool):
|
||||
# Decided here, from the expert row already in hand, so the
|
||||
# thread cannot pick up a looser default written in between.
|
||||
inherited_autopilot_mode=child_autopilot_mode(
|
||||
session.metadata.autopilot_mode, target.autopilot_mode
|
||||
)
|
||||
or DEFAULT_MODE,
|
||||
session, target.autopilot_mode
|
||||
),
|
||||
)
|
||||
return new_session.session_id
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ def _session(
|
||||
session_id: str = "s1",
|
||||
expert_id: str | None = "expert-a",
|
||||
origin: str | None = "interactive",
|
||||
source_platform: str | None = None,
|
||||
) -> MagicMock:
|
||||
sess = MagicMock()
|
||||
sess.session_id = session_id
|
||||
@@ -38,6 +39,7 @@ def _session(
|
||||
sess.metadata.llm_credential_id = None
|
||||
sess.metadata.delegated_by_session_id = None
|
||||
sess.metadata.autopilot_mode = None
|
||||
sess.metadata.source_platform = source_platform
|
||||
# Set explicitly: a bare MagicMock attribute is truthy, so an origin
|
||||
# assertion would pass even if the kwarg were dropped.
|
||||
sess.metadata.origin = origin
|
||||
@@ -238,6 +240,23 @@ class TestDelegatedSessionMode:
|
||||
)
|
||||
assert mock_sessions[0].metadata.autopilot_mode == "auto"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"expert_default, expected",
|
||||
[(None, "auto"), ("unsupervised", "auto"), ("ask_first", "ask_first")],
|
||||
)
|
||||
async def test_a_linked_chat_hands_out_the_mode_its_gate_enforces(
|
||||
self, roster, mock_turn, mock_sessions, expert_default, expected
|
||||
):
|
||||
"""A Slack chat runs on Auto whatever it has stored, so its thread does too."""
|
||||
roster["expert-b"].autopilot_mode = expert_default
|
||||
parent = _session(source_platform="slack")
|
||||
parent.metadata.autopilot_mode = "unsupervised"
|
||||
await DelegateToExpertTool()._execute(
|
||||
user_id="alice", session=parent, expert_id="expert-b", prompt="hi"
|
||||
)
|
||||
assert mock_sessions[0].metadata.autopilot_mode == expected
|
||||
|
||||
|
||||
class TestValidation:
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -28,7 +28,7 @@ from backend.api.features.experts.models import Expert
|
||||
from backend.copilot.active_turns import spawn_limit_message
|
||||
from backend.copilot.budget_signal import build_spawn_state_note
|
||||
from backend.copilot.context import get_current_permissions
|
||||
from backend.copilot.gate.policy import DEFAULT_MODE, child_autopilot_mode
|
||||
from backend.copilot.gate import child_autopilot_mode
|
||||
from backend.copilot.model import (
|
||||
ChatSession,
|
||||
child_session_origin,
|
||||
@@ -186,9 +186,8 @@ class HandoffToExpertTool(BaseTool):
|
||||
# Same cap delegation applies: an Ask-first chat cannot hand its
|
||||
# work to a thread that runs it unsupervised.
|
||||
inherited_autopilot_mode=child_autopilot_mode(
|
||||
session.metadata.autopilot_mode, target.autopilot_mode
|
||||
)
|
||||
or DEFAULT_MODE,
|
||||
session, target.autopilot_mode
|
||||
),
|
||||
)
|
||||
outcome, result = await self._queue_task(
|
||||
user_id, session, inner.session_id, prompt, context
|
||||
|
||||
@@ -35,6 +35,7 @@ def _session(
|
||||
session_id: str = "s1",
|
||||
expert_id: str | None = "expert-a",
|
||||
origin: str | None = "interactive",
|
||||
source_platform: str | None = None,
|
||||
) -> MagicMock:
|
||||
sess = MagicMock()
|
||||
sess.session_id = session_id
|
||||
@@ -48,6 +49,7 @@ def _session(
|
||||
sess.metadata.origin = origin
|
||||
sess.metadata.delegated_by_session_id = None
|
||||
sess.metadata.autopilot_mode = None
|
||||
sess.metadata.source_platform = source_platform
|
||||
sess.expert_id = expert_id
|
||||
return sess
|
||||
|
||||
@@ -579,6 +581,23 @@ class TestHandedOffSessionMode:
|
||||
)
|
||||
assert mock_sessions[0].metadata.autopilot_mode == "auto"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"expert_default, expected",
|
||||
[(None, "auto"), ("unsupervised", "auto"), ("ask_first", "ask_first")],
|
||||
)
|
||||
async def test_a_linked_chat_hands_out_the_mode_its_gate_enforces(
|
||||
self, roster, mock_turn, mock_sessions, expert_default, expected
|
||||
):
|
||||
"""A Slack chat runs on Auto whatever it has stored, so its thread does too."""
|
||||
roster["expert-b"].autopilot_mode = expert_default
|
||||
parent = _session(expert_id="expert-a", source_platform="slack")
|
||||
parent.metadata.autopilot_mode = "unsupervised"
|
||||
await HandoffToExpertTool()._execute(
|
||||
user_id="alice", session=parent, expert_id="expert-b", prompt="own it"
|
||||
)
|
||||
assert mock_sessions[0].metadata.autopilot_mode == expected
|
||||
|
||||
|
||||
class TestTerminalResponse:
|
||||
"""A handoff is terminal for the caller: it names the new owner, links the
|
||||
|
||||
Reference in new issue
Block a user