Перевести исполнение сценариев на MCP workflow runner.
Удален legacy workflow_runner со stub-инструментами, добавлен mcp_client и новый mcp_workflow_runner с planner-моделью через polza.ai, обновлены сценарий, API/AgentOS wiring и документация под текущий контур запуска.
This commit is contained in:
@@ -0,0 +1,546 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextvars import ContextVar
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from agno.agent import Agent
|
||||
from agno.models.openai import OpenAIChat
|
||||
from agno.workflow.step import Step, StepInput, StepOutput
|
||||
from agno.workflow.workflow import Workflow
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from src.mcp_client import call_mcp_tool
|
||||
from src.schemas import RunError, ScenarioRunResponse, StepState
|
||||
from src.scenario_store import ScenarioStoreError, load_scenario_definition
|
||||
|
||||
|
||||
class McpArgumentsPlan(BaseModel):
|
||||
"""Structured planner output for one MCP tool call."""
|
||||
|
||||
arguments: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
_planner_agent: Agent | None = None
|
||||
|
||||
|
||||
def _env_float(name: str, default: float) -> float:
|
||||
value = os.getenv(name)
|
||||
if value is None:
|
||||
return default
|
||||
return float(value)
|
||||
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def get_shared_step_planner_agent() -> Agent:
|
||||
"""
|
||||
Create one reusable planner agent for all workflow steps.
|
||||
|
||||
This agent never calls MCP directly. It only prepares arguments
|
||||
for a fixed MCP method selected by the workflow step.
|
||||
"""
|
||||
global _planner_agent
|
||||
if _planner_agent is not None:
|
||||
return _planner_agent
|
||||
|
||||
model_id = os.getenv("POLZA_MODEL_ID", "google/gemma-4-31b-it")
|
||||
polza_base_url = os.getenv("POLZA_BASE_URL", "https://api.polza.ai/v1")
|
||||
polza_api_key = os.getenv("POLZA_API_KEY") or os.getenv("OPENAI_API_KEY")
|
||||
temperature = _env_float("POLZA_TEMPERATURE", 0.0)
|
||||
|
||||
llm = OpenAIChat(
|
||||
id=model_id,
|
||||
api_key=polza_api_key,
|
||||
base_url=polza_base_url,
|
||||
temperature=temperature,
|
||||
)
|
||||
_planner_agent = Agent(
|
||||
id="workflow-step-planner",
|
||||
model=llm,
|
||||
output_schema=McpArgumentsPlan,
|
||||
markdown=False,
|
||||
debug_mode=False,
|
||||
instructions=[
|
||||
"You are a strict tool-input planner.",
|
||||
"You receive step metadata and current workflow context.",
|
||||
"Return only arguments that should be sent to MCP tool.",
|
||||
"Do not add extra keys that are unrelated to the tool.",
|
||||
"Do not invent values if they are absent in context.",
|
||||
],
|
||||
)
|
||||
return _planner_agent
|
||||
|
||||
|
||||
def _resolve_path(scope: dict[str, Any], path: str) -> Any:
|
||||
value: Any = scope
|
||||
for segment in path.split("."):
|
||||
key = segment.strip()
|
||||
if not key:
|
||||
continue
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
value = value.get(key)
|
||||
return deepcopy(value)
|
||||
|
||||
|
||||
def _resolve_template(template: Any, scope: dict[str, Any]) -> Any:
|
||||
if isinstance(template, dict):
|
||||
if set(template.keys()) == {"from"}:
|
||||
return _resolve_path(scope, str(template["from"]))
|
||||
return {key: _resolve_template(value, scope) for key, value in template.items()}
|
||||
if isinstance(template, list):
|
||||
return [_resolve_template(item, scope) for item in template]
|
||||
return deepcopy(template)
|
||||
|
||||
|
||||
def _validate_required_fields(
|
||||
arguments: dict[str, Any],
|
||||
required_fields: list[str],
|
||||
step_name: str,
|
||||
) -> None:
|
||||
for field in required_fields:
|
||||
value = arguments.get(field)
|
||||
if isinstance(value, str) and value.strip():
|
||||
continue
|
||||
if value not in (None, "", [], {}):
|
||||
continue
|
||||
raise ValueError(f"{step_name}: input.{field} is empty")
|
||||
|
||||
|
||||
class McpWorkflowRunner:
|
||||
"""
|
||||
Minimal workflow runner:
|
||||
- fixed step order from scenario
|
||||
- same planner agent in every step
|
||||
- MCP call executed by code, not by the agent
|
||||
- request/response persisted in run context
|
||||
"""
|
||||
|
||||
def __init__(self, planner_agent: Agent | None = None) -> None:
|
||||
self._planner_agent = planner_agent or get_shared_step_planner_agent()
|
||||
self._workflow_cache: dict[str, Workflow] = {}
|
||||
self._run_state_ctx: ContextVar[dict[str, Any] | None] = ContextVar(
|
||||
"mcp_workflow_run_state",
|
||||
default=None,
|
||||
)
|
||||
|
||||
def _get_run_state(self) -> dict[str, Any]:
|
||||
run_state = self._run_state_ctx.get()
|
||||
if run_state is None:
|
||||
raise RuntimeError("run state is not initialized")
|
||||
return run_state
|
||||
|
||||
def _build_scope(self) -> dict[str, Any]:
|
||||
run_state = self._get_run_state()
|
||||
return {
|
||||
"input": run_state.get("input", {}),
|
||||
"steps": run_state.get("steps", {}),
|
||||
}
|
||||
|
||||
async def _plan_arguments(
|
||||
self,
|
||||
*,
|
||||
step_name: str,
|
||||
tool_name: str,
|
||||
base_arguments: dict[str, Any],
|
||||
required_fields: list[str],
|
||||
scope: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
prompt = {
|
||||
"task": "Prepare MCP arguments for this step.",
|
||||
"step_name": step_name,
|
||||
"tool_name": tool_name,
|
||||
"required_fields": required_fields,
|
||||
"base_arguments": base_arguments,
|
||||
"context": {
|
||||
"input": scope.get("input", {}),
|
||||
"steps": scope.get("steps", {}),
|
||||
},
|
||||
"output": "Return arguments object only.",
|
||||
}
|
||||
run_output = await self._planner_agent.arun(json.dumps(prompt, ensure_ascii=False))
|
||||
content = run_output.content if hasattr(run_output, "content") else {}
|
||||
|
||||
if isinstance(content, McpArgumentsPlan):
|
||||
planned = content.arguments
|
||||
elif isinstance(content, dict):
|
||||
planned = content.get("arguments", {})
|
||||
else:
|
||||
planned = {}
|
||||
|
||||
if not isinstance(planned, dict):
|
||||
planned = {}
|
||||
|
||||
# Allow planner to override/fill base arguments while keeping known defaults.
|
||||
merged = deepcopy(base_arguments)
|
||||
merged.update(planned)
|
||||
return merged
|
||||
|
||||
def _build_tool_step_executor(self, step_spec: dict[str, Any]):
|
||||
step_name = str(step_spec["name"])
|
||||
tool_name = str(step_spec["tool"])
|
||||
input_template = step_spec.get("input", {})
|
||||
foreach_spec = step_spec.get("foreach")
|
||||
collect_template = step_spec.get("collect")
|
||||
collect_key = str(step_spec.get("collect_key", "items")).strip() or "items"
|
||||
required_fields_raw = step_spec.get("required_input_fields", [])
|
||||
required_fields = (
|
||||
[field for field in required_fields_raw if isinstance(field, str)]
|
||||
if isinstance(required_fields_raw, list)
|
||||
else []
|
||||
)
|
||||
if isinstance(foreach_spec, dict):
|
||||
source_path = str(foreach_spec.get("from", "")).strip()
|
||||
item_alias = str(foreach_spec.get("as", "item")).strip() or "item"
|
||||
else:
|
||||
source_path = str(foreach_spec).strip() if isinstance(foreach_spec, str) else ""
|
||||
item_alias = "item"
|
||||
|
||||
async def _executor(_step_input: StepInput) -> StepOutput:
|
||||
run_state = self._get_run_state()
|
||||
scope = self._build_scope()
|
||||
step_started_at = _utc_now_iso()
|
||||
|
||||
try:
|
||||
tool_calls = run_state.setdefault("tool_calls", [])
|
||||
if not isinstance(tool_calls, list):
|
||||
tool_calls = []
|
||||
run_state["tool_calls"] = tool_calls
|
||||
|
||||
if source_path:
|
||||
iterable = _resolve_path(scope, source_path)
|
||||
if not isinstance(iterable, list):
|
||||
raise ValueError(f"{step_name}: foreach source is not list")
|
||||
|
||||
collected_items: list[Any] = []
|
||||
for index, item in enumerate(iterable):
|
||||
iteration_scope = dict(scope)
|
||||
iteration_scope[item_alias] = item
|
||||
iteration_scope["item"] = item
|
||||
iteration_scope["index"] = index
|
||||
|
||||
resolved = _resolve_template(input_template, iteration_scope)
|
||||
base_arguments = resolved if isinstance(resolved, dict) else {}
|
||||
|
||||
final_arguments = await self._plan_arguments(
|
||||
step_name=step_name,
|
||||
tool_name=tool_name,
|
||||
base_arguments=base_arguments,
|
||||
required_fields=required_fields,
|
||||
scope=iteration_scope,
|
||||
)
|
||||
_validate_required_fields(final_arguments, required_fields, step_name)
|
||||
|
||||
tool_response = await call_mcp_tool(tool_name, final_arguments)
|
||||
tool_calls.append(
|
||||
{
|
||||
"step_name": step_name,
|
||||
"tool_name": tool_name,
|
||||
"attempt": index + 1,
|
||||
"request": final_arguments,
|
||||
"ok": True,
|
||||
"response": tool_response,
|
||||
}
|
||||
)
|
||||
|
||||
if collect_template is None:
|
||||
collected_items.append(tool_response.get("payload", {}))
|
||||
else:
|
||||
collected_items.append(
|
||||
_resolve_template(
|
||||
collect_template,
|
||||
{**iteration_scope, "tool": tool_response},
|
||||
)
|
||||
)
|
||||
|
||||
step_payload = {
|
||||
"ok": True,
|
||||
"tool_name": step_name,
|
||||
"payload": {collect_key: collected_items},
|
||||
"request": {"foreach_from": source_path, "count": len(iterable)},
|
||||
"received_at": _utc_now_iso(),
|
||||
"started_at": step_started_at,
|
||||
"finished_at": _utc_now_iso(),
|
||||
}
|
||||
else:
|
||||
resolved = _resolve_template(input_template, scope)
|
||||
base_arguments = resolved if isinstance(resolved, dict) else {}
|
||||
|
||||
final_arguments = await self._plan_arguments(
|
||||
step_name=step_name,
|
||||
tool_name=tool_name,
|
||||
base_arguments=base_arguments,
|
||||
required_fields=required_fields,
|
||||
scope=scope,
|
||||
)
|
||||
_validate_required_fields(final_arguments, required_fields, step_name)
|
||||
|
||||
tool_response = await call_mcp_tool(tool_name, final_arguments)
|
||||
step_payload = {
|
||||
"ok": bool(tool_response.get("ok", True)),
|
||||
"tool_name": tool_name,
|
||||
"payload": tool_response.get("payload", {}),
|
||||
"request": final_arguments,
|
||||
"response": tool_response,
|
||||
"received_at": tool_response.get("received_at"),
|
||||
"started_at": step_started_at,
|
||||
"finished_at": _utc_now_iso(),
|
||||
}
|
||||
tool_calls.append(
|
||||
{
|
||||
"step_name": step_name,
|
||||
"tool_name": tool_name,
|
||||
"request": final_arguments,
|
||||
"ok": True,
|
||||
"response": tool_response,
|
||||
}
|
||||
)
|
||||
|
||||
run_state.setdefault("steps", {})[step_name] = step_payload
|
||||
return StepOutput(
|
||||
content=json.dumps(step_payload, ensure_ascii=False),
|
||||
success=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
error_payload = {
|
||||
"ok": False,
|
||||
"tool_name": tool_name,
|
||||
"request": {},
|
||||
"error": str(exc),
|
||||
"started_at": step_started_at,
|
||||
"finished_at": _utc_now_iso(),
|
||||
}
|
||||
run_state.setdefault("steps", {})[step_name] = error_payload
|
||||
run_state.setdefault("tool_calls", []).append(
|
||||
{
|
||||
"step_name": step_name,
|
||||
"tool_name": tool_name,
|
||||
"request": {},
|
||||
"ok": False,
|
||||
"error": str(exc),
|
||||
}
|
||||
)
|
||||
return StepOutput(
|
||||
content=json.dumps(error_payload, ensure_ascii=False),
|
||||
success=False,
|
||||
)
|
||||
|
||||
return _executor
|
||||
|
||||
def get_workflow(self, scenario_id: str, scenario: dict[str, Any]) -> Workflow:
|
||||
cached = self._workflow_cache.get(scenario_id)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
raw_steps = scenario.get("steps")
|
||||
if not isinstance(raw_steps, list) or not raw_steps:
|
||||
raise ScenarioStoreError("Scenario must contain non-empty steps list")
|
||||
|
||||
workflow_steps: list[Step] = []
|
||||
for raw_step in raw_steps:
|
||||
if not isinstance(raw_step, dict):
|
||||
raise ScenarioStoreError("Each scenario step must be object")
|
||||
if raw_step.get("type") != "tool":
|
||||
raise ScenarioStoreError("This minimal runner supports only tool steps")
|
||||
|
||||
step_name = str(raw_step.get("name", "")).strip()
|
||||
tool_name = str(raw_step.get("tool", step_name)).strip()
|
||||
if not step_name or not tool_name:
|
||||
raise ScenarioStoreError("Each tool step must contain non-empty name and tool")
|
||||
|
||||
executor = self._build_tool_step_executor(raw_step)
|
||||
workflow_steps.append(
|
||||
Step(
|
||||
name=step_name,
|
||||
description=str(raw_step.get("description", step_name)),
|
||||
executor=executor,
|
||||
)
|
||||
)
|
||||
|
||||
workflow = Workflow(
|
||||
name=scenario_id,
|
||||
description=str(scenario.get("description", "")),
|
||||
steps=workflow_steps,
|
||||
)
|
||||
self._workflow_cache[scenario_id] = workflow
|
||||
return workflow
|
||||
|
||||
async def run(self, *, scenario_id: str, input_data: dict[str, Any]) -> dict[str, Any]:
|
||||
scenario = load_scenario_definition(scenario_id)
|
||||
workflow = self.get_workflow(scenario_id, scenario)
|
||||
|
||||
initial_state = {
|
||||
"input": deepcopy(input_data),
|
||||
"steps": {},
|
||||
"tool_calls": [],
|
||||
}
|
||||
token = self._run_state_ctx.set(initial_state)
|
||||
run_state = initial_state
|
||||
run_output: Any = None
|
||||
try:
|
||||
run_output = await workflow.arun(input=input_data)
|
||||
finally:
|
||||
captured = self._run_state_ctx.get()
|
||||
if isinstance(captured, dict):
|
||||
run_state = deepcopy(captured)
|
||||
self._run_state_ctx.reset(token)
|
||||
|
||||
content = run_output.content if hasattr(run_output, "content") else None
|
||||
if isinstance(content, str):
|
||||
try:
|
||||
content = json.loads(content)
|
||||
except json.JSONDecodeError:
|
||||
content = {"raw_content": content}
|
||||
|
||||
return {
|
||||
"scenario_id": scenario_id,
|
||||
"workflow_name": workflow.name,
|
||||
"status": "success"
|
||||
if getattr(run_output, "success", True)
|
||||
else "failed",
|
||||
"input": input_data,
|
||||
"final_result": content if isinstance(content, dict) else {"raw_content": content},
|
||||
"steps": run_state.get("steps", {}),
|
||||
"tool_calls": run_state.get("tool_calls", []),
|
||||
"run_id": str(getattr(run_output, "run_id", "")) or None,
|
||||
"session_id": str(getattr(run_output, "session_id", "")) or None,
|
||||
}
|
||||
|
||||
|
||||
_default_runner: McpWorkflowRunner | None = None
|
||||
|
||||
|
||||
def get_mcp_workflow_runner() -> McpWorkflowRunner:
|
||||
global _default_runner
|
||||
if _default_runner is not None:
|
||||
return _default_runner
|
||||
_default_runner = McpWorkflowRunner()
|
||||
return _default_runner
|
||||
|
||||
|
||||
def _extract_output_summary(content: Any) -> str | None:
|
||||
if not isinstance(content, dict):
|
||||
return None
|
||||
summary = content.get("summary")
|
||||
if isinstance(summary, str) and summary:
|
||||
return summary
|
||||
payload = content.get("payload")
|
||||
if isinstance(payload, dict):
|
||||
payload_summary = payload.get("summary")
|
||||
if isinstance(payload_summary, str) and payload_summary:
|
||||
return payload_summary
|
||||
return None
|
||||
|
||||
|
||||
def _build_step_states_from_minimal(
|
||||
*,
|
||||
scenario: dict[str, Any],
|
||||
minimal_steps: dict[str, Any],
|
||||
) -> list[StepState]:
|
||||
raw_steps = scenario.get("steps")
|
||||
if not isinstance(raw_steps, list):
|
||||
return []
|
||||
|
||||
step_states: list[StepState] = []
|
||||
for raw_step in raw_steps:
|
||||
if not isinstance(raw_step, dict):
|
||||
continue
|
||||
step_name = str(raw_step.get("name", "")).strip()
|
||||
if not step_name:
|
||||
continue
|
||||
payload = minimal_steps.get(step_name)
|
||||
if not isinstance(payload, dict):
|
||||
step_states.append(StepState(node_id=step_name, status="queued"))
|
||||
continue
|
||||
ok = bool(payload.get("ok", False))
|
||||
step_states.append(
|
||||
StepState(
|
||||
node_id=step_name,
|
||||
status="success" if ok else "failed",
|
||||
started_at=str(payload.get("started_at") or "") or None,
|
||||
finished_at=str(payload.get("finished_at") or "") or None,
|
||||
error=RunError(
|
||||
code="tool_error",
|
||||
message=str(payload.get("error", f"{step_name} failed")),
|
||||
)
|
||||
if not ok
|
||||
else None,
|
||||
)
|
||||
)
|
||||
return step_states
|
||||
|
||||
|
||||
async def run_scenario_workflow(
|
||||
input_data: dict[str, Any],
|
||||
scenario_id: str = "news_source_discovery_v1",
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
scenario = load_scenario_definition(scenario_id)
|
||||
except ScenarioStoreError as exc:
|
||||
return ScenarioRunResponse(
|
||||
scenario_id=scenario_id,
|
||||
status="failed",
|
||||
input=input_data,
|
||||
steps=[],
|
||||
error=RunError(code="unknown_scenario", message=str(exc)),
|
||||
).model_dump()
|
||||
|
||||
runner = get_mcp_workflow_runner()
|
||||
scenario_name = str(scenario.get("name", scenario_id))
|
||||
try:
|
||||
minimal_result = await runner.run(
|
||||
scenario_id=scenario_id,
|
||||
input_data=input_data,
|
||||
)
|
||||
except Exception as exc:
|
||||
return ScenarioRunResponse(
|
||||
scenario_id=scenario_id,
|
||||
status="failed",
|
||||
input=input_data,
|
||||
scenario_name=scenario_name,
|
||||
steps=[],
|
||||
error=RunError(code="workflow_error", message=str(exc)),
|
||||
).model_dump()
|
||||
|
||||
minimal_steps = minimal_result.get("steps", {})
|
||||
steps = (
|
||||
minimal_steps
|
||||
if isinstance(minimal_steps, dict)
|
||||
else {}
|
||||
)
|
||||
step_states = _build_step_states_from_minimal(
|
||||
scenario=scenario,
|
||||
minimal_steps=steps,
|
||||
)
|
||||
|
||||
final_result = minimal_result.get("final_result")
|
||||
normalized_result = (
|
||||
final_result if isinstance(final_result, dict) else {"raw_content": str(final_result)}
|
||||
)
|
||||
status = "success"
|
||||
for payload in steps.values():
|
||||
if isinstance(payload, dict) and not bool(payload.get("ok", False)):
|
||||
status = "failed"
|
||||
break
|
||||
|
||||
return ScenarioRunResponse(
|
||||
scenario_id=scenario_id,
|
||||
status=status,
|
||||
input=input_data,
|
||||
steps=step_states,
|
||||
output_summary=_extract_output_summary(normalized_result),
|
||||
scenario_name=scenario_name,
|
||||
workflow_name=str(minimal_result.get("workflow_name") or scenario_id),
|
||||
result=normalized_result,
|
||||
error=None
|
||||
if status == "success"
|
||||
else RunError(code="workflow_failed", message="Workflow finished with failed status."),
|
||||
run_id=minimal_result.get("run_id"),
|
||||
session_id=minimal_result.get("session_id"),
|
||||
).model_dump()
|
||||
Reference in New Issue
Block a user