|
1 | 1 | """Orchestration manager (agent_framework version) handling multi-agent Magentic workflow creation and execution.""" |
2 | 2 |
|
3 | 3 | import asyncio |
| 4 | +import json |
4 | 5 | import logging |
5 | 6 | import uuid |
6 | 7 | import re |
@@ -198,6 +199,13 @@ async def init_orchestration( |
198 | 199 | len(participant_list), |
199 | 200 | ) |
200 | 201 |
|
| 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 | + |
201 | 209 | return workflow |
202 | 210 |
|
203 | 211 | # --------------------------- |
@@ -364,6 +372,25 @@ async def run_orchestration(self, user_id: str, input_task) -> None: |
364 | 372 | task_text = getattr(input_task, "description", str(input_task)) |
365 | 373 | self.logger.debug("Task: %s", task_text) |
366 | 374 |
|
| 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 | + |
367 | 394 | try: |
368 | 395 | final_output_ref: list = [None] |
369 | 396 | orchestrator_chunks: list[str] = [] |
@@ -534,6 +561,244 @@ async def _cleanup_workflow_mcp(self, user_id: str) -> None: |
534 | 561 | # Mark workflow as terminated so next request creates a fresh one |
535 | 562 | workflow._terminated = True |
536 | 563 |
|
| 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 | + |
537 | 802 | # --------------------------- |
538 | 803 | # Plan review handling |
539 | 804 | # --------------------------- |
|
0 commit comments