Skip to content

Commit 08aba9d

Browse files
fix cross team task handling
1 parent 5b02de9 commit 08aba9d

2 files changed

Lines changed: 299 additions & 2 deletions

File tree

src/backend/orchestration/orchestration_manager.py

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Orchestration manager (agent_framework version) handling multi-agent Magentic workflow creation and execution."""
22

33
import asyncio
4+
import json
45
import logging
56
import uuid
67
import re
@@ -198,6 +199,13 @@ async def init_orchestration(
198199
len(participant_list),
199200
)
200201

202+
# Attach context needed for the pre-planning team-scope gate
203+
# (see run_orchestration → _evaluate_team_scope). Stored on the workflow
204+
# so the gate can classify a request against this team's agents/data
205+
# without rebuilding a chat client.
206+
workflow._team_config = team_config
207+
workflow._manager_chat_client = manager_chat_client
208+
201209
return workflow
202210

203211
# ---------------------------
@@ -364,6 +372,25 @@ async def run_orchestration(self, user_id: str, input_task) -> None:
364372
task_text = getattr(input_task, "description", str(input_task))
365373
self.logger.debug("Task: %s", task_text)
366374

375+
# ---- Team-scope gate (generic, team-agnostic) -------------------
376+
377+
scope = await self._evaluate_team_scope(workflow, task_text)
378+
if scope is not None and not scope.get("in_scope", True):
379+
self.logger.info(
380+
"Request judged OUT OF SCOPE for team; presenting single "
381+
"MagenticManager out-of-scope step (job='%s')", job_id,
382+
)
383+
384+
team_agent_names = self._get_team_agent_names(workflow)
385+
await self._handle_out_of_scope(
386+
user_id=user_id,
387+
task_text=task_text,
388+
out_of_scope_message=scope.get("message", ""),
389+
team_agent_names=team_agent_names,
390+
)
391+
await self._cleanup_workflow_mcp(user_id)
392+
return
393+
367394
try:
368395
final_output_ref: list = [None]
369396
orchestrator_chunks: list[str] = []
@@ -534,6 +561,244 @@ async def _cleanup_workflow_mcp(self, user_id: str) -> None:
534561
# Mark workflow as terminated so next request creates a fresh one
535562
workflow._terminated = True
536563

564+
# ---------------------------
565+
# Team-scope gate
566+
# ---------------------------
567+
async def _evaluate_team_scope(self, workflow, task_text: str) -> Optional[dict]:
568+
"""Classify whether ``task_text`` is within the current team's scope.
569+
570+
The decision is made by a focused single-purpose classifier call using
571+
the manager chat client, given the team's purpose, its agents (and the
572+
data/knowledge each works on), and representative example tasks. This is
573+
deliberately separate from the planning prompt so the strict scope
574+
decision is not diluted by the "include every agent" planning rules.
575+
576+
Returns:
577+
``{"in_scope": bool, "message": str}`` when the classification
578+
succeeds, or ``None`` when it cannot be evaluated (missing context or
579+
an error) — in which case the caller proceeds normally (fail-open).
580+
"""
581+
team_config = getattr(workflow, "_team_config", None)
582+
chat_client = getattr(workflow, "_manager_chat_client", None)
583+
if team_config is None or chat_client is None or not task_text:
584+
return None
585+
586+
try:
587+
agent_lines = []
588+
for ag in getattr(team_config, "agents", []) or []:
589+
name = getattr(ag, "name", "") or ""
590+
desc = getattr(ag, "description", "") or ""
591+
data = getattr(ag, "knowledge_base_name", "") or ""
592+
line = f"- {name}: {desc}"
593+
if data:
594+
line += f" (works on data: {data})"
595+
agent_lines.append(line)
596+
agents_block = "\n".join(agent_lines) or "- (no agents listed)"
597+
598+
example_lines = []
599+
for t in getattr(team_config, "starting_tasks", []) or []:
600+
tname = getattr(t, "name", "") or ""
601+
tprompt = getattr(t, "prompt", "") or ""
602+
example_lines.append(f"- {tname}: {tprompt}".strip())
603+
examples_block = "\n".join(example_lines) or "- (none provided)"
604+
605+
system_prompt = (
606+
"You are a strict scope classifier for a specialized multi-agent "
607+
"team. Decide whether a user's request falls within THIS team's "
608+
"specialization.\n\n"
609+
"A team is defined ENTIRELY by its stated purpose, the specific "
610+
"agents it has and what each does, the data/knowledge those agents "
611+
"work with, and its representative example tasks.\n\n"
612+
"Rules:\n"
613+
"- IN SCOPE only if the request clearly matches this team's "
614+
"specialization and could be fulfilled by these agents using their "
615+
"data.\n"
616+
"- OUT OF SCOPE if the request belongs to a DIFFERENT "
617+
"specialization, even when superficially related or in a broadly "
618+
"similar field (e.g. drafting a product press release is NOT the "
619+
"same specialization as generating retail social-media content; HR "
620+
"onboarding is NOT product marketing; contract/NDA compliance is "
621+
"NOT RFP evaluation).\n"
622+
"- If the request is genuinely ambiguous or a reasonable subset of "
623+
"the example tasks, treat it as IN SCOPE.\n\n"
624+
"Respond with ONLY a compact JSON object and nothing else:\n"
625+
'{"in_scope": true|false, "reason": "<one sentence>", '
626+
'"message": "<empty string if in scope; otherwise a short, polite '
627+
"message telling the user this request is outside this team's scope "
628+
"and that they should switch to the appropriate team and try again. "
629+
"Do NOT name, recommend, or guess any specific team; do NOT list "
630+
'what this team specializes in>"}'
631+
)
632+
user_prompt = (
633+
f"TEAM NAME: {getattr(team_config, 'name', '')}\n"
634+
f"TEAM PURPOSE: {getattr(team_config, 'description', '')}\n\n"
635+
f"AGENTS:\n{agents_block}\n\n"
636+
f"EXAMPLE IN-SCOPE TASKS:\n{examples_block}\n\n"
637+
f"USER REQUEST:\n{task_text}"
638+
)
639+
640+
response = await chat_client.get_response(
641+
[Message("system", [system_prompt]),
642+
Message("user", [user_prompt])]
643+
)
644+
raw = (getattr(response, "text", "") or "").strip()
645+
self.logger.info("[SCOPE-GATE] classifier raw response: %s", raw[:500])
646+
647+
parsed = self._parse_scope_json(raw)
648+
if parsed is None:
649+
self.logger.warning(
650+
"[SCOPE-GATE] Could not parse classifier output — proceeding "
651+
"normally (fail-open)."
652+
)
653+
return None
654+
655+
in_scope = bool(parsed.get("in_scope", True))
656+
message = str(parsed.get("message", "") or "").strip()
657+
if not in_scope and not message:
658+
message = (
659+
"This request appears to be outside the scope of the selected "
660+
"team, so it cannot be handled reliably here. Please switch to "
661+
"the appropriate team and try again."
662+
)
663+
self.logger.info(
664+
"[SCOPE-GATE] in_scope=%s reason=%s",
665+
in_scope, parsed.get("reason", ""),
666+
)
667+
return {"in_scope": in_scope, "message": message}
668+
except Exception as e: # fail-open: never block a task on classifier error
669+
self.logger.warning(
670+
"[SCOPE-GATE] Scope evaluation failed (%s) — proceeding normally.", e
671+
)
672+
return None
673+
674+
@staticmethod
675+
def _parse_scope_json(text: str) -> Optional[dict]:
676+
"""Extract the first JSON object from a classifier response."""
677+
if not text:
678+
return None
679+
cleaned = text.strip()
680+
if cleaned.startswith("```"):
681+
cleaned = "\n".join(
682+
ln for ln in cleaned.splitlines() if not ln.strip().startswith("```")
683+
).strip()
684+
try:
685+
return json.loads(cleaned)
686+
except (json.JSONDecodeError, ValueError):
687+
pass
688+
m = re.search(r"\{.*\}", cleaned, re.DOTALL)
689+
if m:
690+
try:
691+
return json.loads(m.group(0))
692+
except (json.JSONDecodeError, ValueError):
693+
return None
694+
return None
695+
696+
@staticmethod
697+
def _get_team_agent_names(workflow) -> list[str]:
698+
"""Return the plan ``team`` roster for the frontend "Agent Team" panel.
699+
700+
Mirrors the normal plan exactly: ``run_orchestration`` builds its
701+
``participant_names`` from ``workflow.get_executors_list()`` executor ids
702+
(which include the ``magentic_orchestrator`` shown as "Magentic
703+
Orchestrator"). Using the same source keeps the out-of-scope Agent Team
704+
identical to a normal plan's. Falls back to the stored team config's
705+
agent names when executors are unavailable.
706+
"""
707+
try:
708+
names = [
709+
executor.id
710+
for executor in workflow.get_executors_list()
711+
if getattr(executor, "id", "")
712+
]
713+
if names:
714+
return names
715+
except Exception:
716+
pass
717+
718+
team_config = getattr(workflow, "_team_config", None)
719+
return [
720+
getattr(ag, "name", "")
721+
for ag in getattr(team_config, "agents", []) or []
722+
if getattr(ag, "name", "")
723+
]
724+
725+
async def _handle_out_of_scope(
726+
self,
727+
*,
728+
user_id: str,
729+
task_text: str,
730+
out_of_scope_message: str,
731+
team_agent_names: Optional[list[str]] = None,
732+
) -> None:
733+
"""Present a single MagenticManager out-of-scope step for approval, then
734+
deliver the out-of-scope notice as the final answer (no agents run)."""
735+
from models.plan_models import MPlan, MStep
736+
737+
message = out_of_scope_message or (
738+
"This request appears to be outside the scope of the selected team. "
739+
"Please switch to the appropriate team and try again."
740+
)
741+
742+
team = list(team_agent_names) if team_agent_names else ["MagenticManager"]
743+
744+
mplan = MPlan()
745+
mplan.user_id = user_id
746+
mplan.user_request = task_text
747+
mplan.team = team
748+
mplan.steps = [
749+
MStep(
750+
agent="MagenticManager",
751+
action=(
752+
"Inform the user that this request is out of scope for the "
753+
"selected team and suggest a suitable team."
754+
),
755+
)
756+
]
757+
758+
try:
759+
orchestration_config.plans[mplan.id] = mplan
760+
except Exception as e:
761+
self.logger.error("Error storing out-of-scope plan: %s", e)
762+
763+
approval_message = messages.PlanApprovalRequest(
764+
plan=mplan,
765+
status="PENDING_APPROVAL", # type: ignore[arg-type]
766+
context={"task": task_text, "out_of_scope": True},
767+
)
768+
await connection_config.send_status_update_async(
769+
message=approval_message,
770+
user_id=user_id,
771+
message_type=WebsocketMessageType.PLAN_APPROVAL_REQUEST,
772+
)
773+
774+
approval_response = await wait_for_plan_approval(mplan.id, user_id)
775+
776+
if approval_response and approval_response.approved:
777+
self.logger.info("Out-of-scope plan approved — sending final notice.")
778+
await asyncio.sleep(1.5)
779+
await connection_config.send_status_update_async(
780+
{
781+
"type": WebsocketMessageType.FINAL_RESULT_MESSAGE,
782+
"data": {
783+
"content": message,
784+
"status": "completed",
785+
"timestamp": asyncio.get_event_loop().time(),
786+
},
787+
},
788+
user_id,
789+
message_type=WebsocketMessageType.FINAL_RESULT_MESSAGE,
790+
)
791+
else:
792+
self.logger.info("Out-of-scope plan rejected by user.")
793+
await connection_config.send_status_update_async(
794+
{
795+
"type": WebsocketMessageType.PLAN_APPROVAL_RESPONSE,
796+
"data": approval_response,
797+
},
798+
user_id=user_id,
799+
message_type=WebsocketMessageType.PLAN_APPROVAL_RESPONSE,
800+
)
801+
537802
# ---------------------------
538803
# Plan review handling
539804
# ---------------------------

src/backend/orchestration/plan_review_helpers.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,9 @@ def get_magentic_prompt_kwargs(
8484
agent_lines = "\n".join(f"- {name}" for name in participant_names)
8585
mandatory_block = (
8686
"\n\nMANDATORY AGENTS (CRITICAL — NON-NEGOTIABLE):\n"
87-
"Every plan you generate MUST include EVERY ONE of the following agents,\n"
87+
"UNLESS the request is out of scope (see TEAM SCOPE POLICY above — in\n"
88+
"which case the single MagenticManager step is the entire plan),\n"
89+
"every plan you generate MUST include EVERY ONE of the following agents,\n"
8890
"each as its own distinct step, each invoked at least once:\n"
8991
+ agent_lines
9092
+ "\nDo NOT omit any of these agents, even if a step seems optional,\n"
@@ -93,7 +95,28 @@ def get_magentic_prompt_kwargs(
9395
"is INVALID — regenerate it\nuntil every listed agent appears as a step.\n"
9496
)
9597

96-
plan_append = """
98+
99+
scope_policy = """
100+
101+
TEAM SCOPE POLICY (CRITICAL — EVALUATE THIS FIRST, BEFORE ANY OTHER RULE):
102+
This team can ONLY handle requests that fall within the collective expertise of
103+
its listed agents. Each agent's description above tells you its domain and the
104+
data/knowledge it works on — that, and nothing else, defines the team's scope.
105+
106+
Judge whether the user's request is covered by at least one listed agent's domain:
107+
- IN SCOPE: at least one agent's expertise/data is clearly relevant → plan normally.
108+
- OUT OF SCOPE: NO listed agent's domain covers the request (e.g. a contract /
109+
compliance request given to a marketing or RFP team, or an HR / onboarding
110+
request given to a product-marketing team). In this case you MUST NOT invoke any
111+
domain agent. Output a plan with EXACTLY ONE step and nothing else:
112+
[{{"agent": "MagenticManager", "action": "Inform the user that this request is out of scope for this team and that they should switch to the appropriate team and try again, without naming any specific team."}}]
113+
114+
When out of scope, the mandatory-inclusion rule below does NOT apply — the lone
115+
MagenticManager step IS the complete, valid plan. Only take this path when the
116+
request is genuinely outside every agent's domain; when in doubt, plan normally.
117+
"""
118+
119+
plan_append = scope_policy + """
97120
98121
PLAN RULES:
99122
- Steps are HIGH-LEVEL task assignments — one step per agent. Do NOT prescribe
@@ -137,6 +160,11 @@ def get_magentic_prompt_kwargs(
137160
final_append = """
138161
139162
FINAL ANSWER RULES:
163+
- If the approved plan was a single out-of-scope MagenticManager step (no domain
164+
agents ran), your final answer is a brief, polite message that states the
165+
request is out of scope for this team and that the user should switch to the
166+
appropriate team and try again. Do NOT name, recommend, or guess any specific
167+
team, and do NOT attempt to answer the out-of-scope request itself.
140168
- Compile ONLY from messages agents actually produced. Quote verbatim where appropriate.
141169
- Do NOT fabricate URLs, results, or content that no agent produced.
142170
- If a required agent step did not run, state it plainly — do not pretend it did.
@@ -183,6 +211,10 @@ def get_magentic_prompt_kwargs(
183211
work agents. It only routes tasks and compiles the final output.
184212
185213
COMPLETION CHECK (CRITICAL):
214+
- OUT-OF-SCOPE PLAN: If the approved plan is a single MagenticManager out-of-scope
215+
step (no domain agents), there is nothing to route. Set is_request_satisfied to
216+
true immediately and produce the out-of-scope final answer — do NOT select any
217+
next_speaker and do NOT invoke domain agents.
186218
Before setting is_request_satisfied to true, you MUST verify:
187219
1. Review the conversation history and list every agent that has actually produced
188220
a substantive response (meaningful output — calling tools and returning results

0 commit comments

Comments
 (0)