|
|
@@ -1,12 +1,16 @@
|
|
|
from abc import ABC, abstractmethod
|
|
|
from dataclasses import dataclass
|
|
|
+from datetime import datetime, timedelta
|
|
|
import re
|
|
|
+from typing import cast
|
|
|
|
|
|
import httpx
|
|
|
from core_domain import (
|
|
|
ChatCompletionRequestContract,
|
|
|
ChatMessageContract,
|
|
|
CodeExecutionRequestContract,
|
|
|
+ HumanTaskCreateContract,
|
|
|
+ HumanTaskType,
|
|
|
KnowledgeSearchRequestContract,
|
|
|
NodeExecutionContextContract,
|
|
|
NodeExecutionRequestContract,
|
|
|
@@ -24,6 +28,7 @@ from .context import (
|
|
|
render_template_string,
|
|
|
resolve_expression,
|
|
|
)
|
|
|
+from .human_client import HumanServiceClient, HumanServiceClientError
|
|
|
from .knowledge_client import KnowledgeServiceClient, KnowledgeServiceClientError
|
|
|
from .model_gateway_client import ModelGatewayClient, ModelGatewayClientError
|
|
|
from .tool_client import ToolServiceClient, ToolServiceClientError
|
|
|
@@ -381,6 +386,142 @@ class CodeNodeExecutor(CompletedNodeExecutor):
|
|
|
)
|
|
|
|
|
|
|
|
|
+class HumanNodeExecutor(CompletedNodeExecutor):
|
|
|
+ def __init__(self, human_client: HumanServiceClient | None = None) -> None:
|
|
|
+ super().__init__(
|
|
|
+ executor_name="human-executor",
|
|
|
+ supported_node_types=frozenset(
|
|
|
+ {"human", "approval", "human-input", "human-takeover"}
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ self.human_client = human_client
|
|
|
+
|
|
|
+ def execute(
|
|
|
+ self,
|
|
|
+ context: NodeExecutionContextContract,
|
|
|
+ request: NodeExecutionRequestContract,
|
|
|
+ ) -> NodeExecutionResultContract:
|
|
|
+ worker_key = request.worker_key or f"{self.executor_name}:{context.node_type}"
|
|
|
+ if self.human_client is None:
|
|
|
+ return NodeExecutionResultContract(
|
|
|
+ status="failed",
|
|
|
+ worker_key=worker_key,
|
|
|
+ error_code="human_service_missing",
|
|
|
+ error_message="human service client is not configured",
|
|
|
+ )
|
|
|
+
|
|
|
+ human_task_id = _resolve_existing_human_task_id(context)
|
|
|
+ if human_task_id is None:
|
|
|
+ return self._create_waiting_task(context=context, worker_key=worker_key)
|
|
|
+
|
|
|
+ try:
|
|
|
+ task = self.human_client.get_task(
|
|
|
+ tenant_id=context.tenant_id,
|
|
|
+ human_task_id=human_task_id,
|
|
|
+ )
|
|
|
+ except HumanServiceClientError as exc:
|
|
|
+ return NodeExecutionResultContract(
|
|
|
+ status="failed",
|
|
|
+ worker_key=worker_key,
|
|
|
+ error_code="human_task_lookup_failed",
|
|
|
+ error_message=str(exc),
|
|
|
+ )
|
|
|
+
|
|
|
+ output_json: dict[str, JSONValue] = {
|
|
|
+ "executor_name": self.executor_name,
|
|
|
+ "human_task_id": task.id,
|
|
|
+ "human_task_status": task.status,
|
|
|
+ "response_payload_json": task.response_payload_json or {},
|
|
|
+ }
|
|
|
+ if task.status in {"pending", "claimed"}:
|
|
|
+ return NodeExecutionResultContract(
|
|
|
+ status="pending",
|
|
|
+ worker_key=worker_key,
|
|
|
+ output_text=f"waiting for human task: {task.id}",
|
|
|
+ output_json=output_json,
|
|
|
+ )
|
|
|
+ if task.status in {"approved", "completed"}:
|
|
|
+ return NodeExecutionResultContract(
|
|
|
+ status="completed",
|
|
|
+ worker_key=worker_key,
|
|
|
+ output_json=output_json,
|
|
|
+ )
|
|
|
+ return NodeExecutionResultContract(
|
|
|
+ status="failed",
|
|
|
+ worker_key=worker_key,
|
|
|
+ error_code=f"human_task_{task.status}",
|
|
|
+ error_message=f"human task ended with status: {task.status}",
|
|
|
+ output_json=output_json,
|
|
|
+ )
|
|
|
+
|
|
|
+ def _create_waiting_task(
|
|
|
+ self,
|
|
|
+ *,
|
|
|
+ context: NodeExecutionContextContract,
|
|
|
+ worker_key: str,
|
|
|
+ ) -> NodeExecutionResultContract:
|
|
|
+ render_context = _build_executor_template_context(context)
|
|
|
+ task_type = _resolve_human_task_type(context.node_type, context.node_config_json)
|
|
|
+ title = render_template_string(
|
|
|
+ _read_string_value(context.node_config_json, "title")
|
|
|
+ or f"Human task for {context.node_id}",
|
|
|
+ render_context,
|
|
|
+ )
|
|
|
+ description_template = _read_string_value(context.node_config_json, "description")
|
|
|
+ description = (
|
|
|
+ render_template_string(description_template, render_context)
|
|
|
+ if description_template is not None
|
|
|
+ else None
|
|
|
+ )
|
|
|
+ request_payload_json = _render_json_dict(
|
|
|
+ _read_dict_value(context.node_config_json, "request_payload_json"),
|
|
|
+ render_context,
|
|
|
+ )
|
|
|
+
|
|
|
+ try:
|
|
|
+ task = self.human_client.create_task(
|
|
|
+ HumanTaskCreateContract(
|
|
|
+ tenant_id=context.tenant_id,
|
|
|
+ task_type=task_type,
|
|
|
+ title=title,
|
|
|
+ description=description,
|
|
|
+ source_type="runtime-node",
|
|
|
+ source_id=context.node_run_id,
|
|
|
+ run_id=context.run_id,
|
|
|
+ node_run_id=context.node_run_id,
|
|
|
+ requested_by=_read_string_value(
|
|
|
+ context.node_config_json,
|
|
|
+ "requested_by",
|
|
|
+ ),
|
|
|
+ assigned_to=_read_string_value(
|
|
|
+ context.node_config_json,
|
|
|
+ "assigned_to",
|
|
|
+ ),
|
|
|
+ request_payload_json=request_payload_json,
|
|
|
+ due_time=_resolve_due_time(context.node_config_json),
|
|
|
+ )
|
|
|
+ )
|
|
|
+ except HumanServiceClientError as exc:
|
|
|
+ return NodeExecutionResultContract(
|
|
|
+ status="failed",
|
|
|
+ worker_key=worker_key,
|
|
|
+ error_code="human_task_create_failed",
|
|
|
+ error_message=str(exc),
|
|
|
+ )
|
|
|
+
|
|
|
+ return NodeExecutionResultContract(
|
|
|
+ status="pending",
|
|
|
+ worker_key=worker_key,
|
|
|
+ output_text=f"waiting for human task: {task.id}",
|
|
|
+ output_json={
|
|
|
+ "executor_name": self.executor_name,
|
|
|
+ "human_task_id": task.id,
|
|
|
+ "human_task_status": task.status,
|
|
|
+ "task_type": task.task_type,
|
|
|
+ },
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
class AnswerNodeExecutor(CompletedNodeExecutor):
|
|
|
def execute(
|
|
|
self,
|
|
|
@@ -692,6 +833,7 @@ def build_node_execution_dispatcher() -> NodeExecutionDispatcher:
|
|
|
LLMNodeExecutor(),
|
|
|
ToolNodeExecutor(),
|
|
|
CodeNodeExecutor(),
|
|
|
+ HumanNodeExecutor(),
|
|
|
AnswerNodeExecutor(),
|
|
|
ConditionNodeExecutor(),
|
|
|
AssignerNodeExecutor(),
|
|
|
@@ -710,11 +852,13 @@ def build_node_execution_dispatcher_with_clients(
|
|
|
model_gateway_client: ModelGatewayClient | None = None,
|
|
|
tool_client: ToolServiceClient | None = None,
|
|
|
knowledge_client: KnowledgeServiceClient | None = None,
|
|
|
+ human_client: HumanServiceClient | None = None,
|
|
|
) -> NodeExecutionDispatcher:
|
|
|
executors: list[NodeExecutor] = [
|
|
|
LLMNodeExecutor(model_gateway_client=model_gateway_client),
|
|
|
ToolNodeExecutor(tool_client=tool_client),
|
|
|
CodeNodeExecutor(code_runner_client=code_runner_client),
|
|
|
+ HumanNodeExecutor(human_client=human_client),
|
|
|
AnswerNodeExecutor(),
|
|
|
ConditionNodeExecutor(),
|
|
|
AssignerNodeExecutor(),
|
|
|
@@ -868,6 +1012,55 @@ def _read_int_value(payload: dict[str, JSONValue], key: str) -> int | None:
|
|
|
return None
|
|
|
|
|
|
|
|
|
+def _resolve_existing_human_task_id(context: NodeExecutionContextContract) -> str | None:
|
|
|
+ configured_task_id = _read_string_value(context.node_config_json, "human_task_id")
|
|
|
+ if configured_task_id is not None:
|
|
|
+ return configured_task_id
|
|
|
+
|
|
|
+ current_node_output = context.node_output_json_by_node_id.get(context.node_id)
|
|
|
+ if current_node_output is None:
|
|
|
+ return None
|
|
|
+ return _read_string_value(current_node_output, "human_task_id")
|
|
|
+
|
|
|
+
|
|
|
+def _resolve_human_task_type(
|
|
|
+ node_type: str,
|
|
|
+ payload: dict[str, JSONValue],
|
|
|
+) -> HumanTaskType:
|
|
|
+ configured_task_type = _read_string_value(payload, "task_type")
|
|
|
+ if configured_task_type in {"approval", "input", "takeover", "pause", "resume"}:
|
|
|
+ return cast(HumanTaskType, configured_task_type)
|
|
|
+ if node_type == "approval":
|
|
|
+ return "approval"
|
|
|
+ if node_type == "human-input":
|
|
|
+ return "input"
|
|
|
+ if node_type == "human-takeover":
|
|
|
+ return "takeover"
|
|
|
+ return "input"
|
|
|
+
|
|
|
+
|
|
|
+def _resolve_due_time(payload: dict[str, JSONValue]) -> datetime | None:
|
|
|
+ due_time = _read_datetime_value(payload, "due_time")
|
|
|
+ if due_time is not None:
|
|
|
+ return due_time
|
|
|
+
|
|
|
+ due_after_seconds = _read_int_value(payload, "due_after_seconds")
|
|
|
+ if due_after_seconds is None or due_after_seconds <= 0:
|
|
|
+ return None
|
|
|
+ return datetime.utcnow() + timedelta(seconds=due_after_seconds)
|
|
|
+
|
|
|
+
|
|
|
+def _read_datetime_value(payload: dict[str, JSONValue], key: str) -> datetime | None:
|
|
|
+ value = payload.get(key)
|
|
|
+ if isinstance(value, str) and value.strip():
|
|
|
+ normalized_value = value.strip().replace("Z", "+00:00")
|
|
|
+ try:
|
|
|
+ return datetime.fromisoformat(normalized_value)
|
|
|
+ except ValueError:
|
|
|
+ return None
|
|
|
+ return None
|
|
|
+
|
|
|
+
|
|
|
def _build_executor_template_context(context: NodeExecutionContextContract) -> dict[str, JSONValue]:
|
|
|
return build_template_context(
|
|
|
node_id=context.node_id,
|