hanzo-tasks 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hanzo_tasks-0.1.0/PKG-INFO +23 -0
- hanzo_tasks-0.1.0/hanzo_tasks/__init__.py +43 -0
- hanzo_tasks-0.1.0/hanzo_tasks/activities.py +35 -0
- hanzo_tasks-0.1.0/hanzo_tasks/client.py +100 -0
- hanzo_tasks-0.1.0/hanzo_tasks/py.typed +1 -0
- hanzo_tasks-0.1.0/hanzo_tasks/worker.py +54 -0
- hanzo_tasks-0.1.0/hanzo_tasks/workflows.py +80 -0
- hanzo_tasks-0.1.0/hanzo_tasks.egg-info/PKG-INFO +23 -0
- hanzo_tasks-0.1.0/hanzo_tasks.egg-info/SOURCES.txt +13 -0
- hanzo_tasks-0.1.0/hanzo_tasks.egg-info/dependency_links.txt +1 -0
- hanzo_tasks-0.1.0/hanzo_tasks.egg-info/requires.txt +5 -0
- hanzo_tasks-0.1.0/hanzo_tasks.egg-info/top_level.txt +1 -0
- hanzo_tasks-0.1.0/pyproject.toml +43 -0
- hanzo_tasks-0.1.0/setup.cfg +4 -0
- hanzo_tasks-0.1.0/tests/test_tasks.py +317 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hanzo-tasks
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Hanzo Tasks SDK — Durable workflow execution for AI agents (powered by Temporal)
|
|
5
|
+
Author-email: Hanzo AI <dev@hanzo.ai>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/hanzoai/python-sdk
|
|
8
|
+
Project-URL: Repository, https://github.com/hanzoai/python-sdk/tree/main/pkg/hanzo-tasks
|
|
9
|
+
Project-URL: Documentation, https://hanzo.ai/docs/tasks
|
|
10
|
+
Keywords: hanzo,temporal,tasks,workflow,durable,agents
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.12
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Requires-Dist: temporalio>=1.9.0
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest-asyncio>=0.26.0; extra == "dev"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""
|
|
2
|
+
hanzo-tasks — Durable workflow execution for AI agents.
|
|
3
|
+
|
|
4
|
+
Wraps the Temporal Python SDK with Hanzo conventions for agent task
|
|
5
|
+
orchestration, including pre-built workflows for pipelines and fan-out.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
>>> from hanzo_tasks import Client, TasksConfig
|
|
9
|
+
>>> client = await Client.connect(TasksConfig(namespace="hanzo"))
|
|
10
|
+
>>> handle = await client.submit(AgentTaskWorkflow.run, task_input, queue="agents")
|
|
11
|
+
>>> result = await handle.result()
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .activities import execute_agent_task, send_notification, set_agent_executor
|
|
15
|
+
from .client import Client, TasksConfig, WorkflowHandle
|
|
16
|
+
from .worker import Worker
|
|
17
|
+
from .workflows import (
|
|
18
|
+
AgentTaskInput,
|
|
19
|
+
AgentTaskOutput,
|
|
20
|
+
AgentTaskWorkflow,
|
|
21
|
+
FanOutWorkflow,
|
|
22
|
+
PipelineWorkflow,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__version__ = "0.1.0"
|
|
26
|
+
__all__ = [
|
|
27
|
+
# Client
|
|
28
|
+
"Client",
|
|
29
|
+
"TasksConfig",
|
|
30
|
+
"WorkflowHandle",
|
|
31
|
+
# Worker
|
|
32
|
+
"Worker",
|
|
33
|
+
# Workflows
|
|
34
|
+
"AgentTaskWorkflow",
|
|
35
|
+
"PipelineWorkflow",
|
|
36
|
+
"FanOutWorkflow",
|
|
37
|
+
"AgentTaskInput",
|
|
38
|
+
"AgentTaskOutput",
|
|
39
|
+
# Activities
|
|
40
|
+
"execute_agent_task",
|
|
41
|
+
"send_notification",
|
|
42
|
+
"set_agent_executor",
|
|
43
|
+
]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Activity definitions for agent task execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Callable
|
|
6
|
+
|
|
7
|
+
from temporalio import activity
|
|
8
|
+
|
|
9
|
+
# Activity executor — pluggable. The playground sets this to call the ZAP sidecar.
|
|
10
|
+
_agent_executor: Callable[..., Any] | None = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def set_agent_executor(fn: Callable[..., Any]) -> None:
|
|
14
|
+
"""Set the function that executes agent tasks (called by playground)."""
|
|
15
|
+
global _agent_executor
|
|
16
|
+
_agent_executor = fn
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@activity.defn
|
|
20
|
+
async def execute_agent_task(input: Any) -> Any:
|
|
21
|
+
"""Execute an agent task. Delegates to the registered executor."""
|
|
22
|
+
if _agent_executor is None:
|
|
23
|
+
raise RuntimeError(
|
|
24
|
+
"No agent executor registered. Call set_agent_executor() first."
|
|
25
|
+
)
|
|
26
|
+
return await _agent_executor(input)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@activity.defn
|
|
30
|
+
async def send_notification(input: dict[str, Any]) -> None:
|
|
31
|
+
"""Send a notification (webhook, SSE, etc)."""
|
|
32
|
+
import httpx
|
|
33
|
+
|
|
34
|
+
async with httpx.AsyncClient() as client:
|
|
35
|
+
await client.post(input.get("url", ""), json=input)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Hanzo Tasks client — wraps Temporal client with Hanzo conventions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
from temporalio.client import Client as TemporalClient
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class TasksConfig:
|
|
14
|
+
"""Configuration for connecting to Temporal."""
|
|
15
|
+
|
|
16
|
+
address: str = "localhost:7233"
|
|
17
|
+
namespace: str = "hanzo"
|
|
18
|
+
tls: bool = False
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class WorkflowHandle:
|
|
22
|
+
"""Handle to a running workflow."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, handle: Any) -> None:
|
|
25
|
+
self._handle = handle
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def id(self) -> str:
|
|
29
|
+
return self._handle.id
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def run_id(self) -> str:
|
|
33
|
+
return self._handle.result_run_id
|
|
34
|
+
|
|
35
|
+
async def result(self, result_type: type | None = None) -> Any:
|
|
36
|
+
return await self._handle.result(result_type=result_type)
|
|
37
|
+
|
|
38
|
+
async def cancel(self) -> None:
|
|
39
|
+
await self._handle.cancel()
|
|
40
|
+
|
|
41
|
+
async def signal(self, name: str, data: Any = None) -> None:
|
|
42
|
+
await self._handle.signal(name, data)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Client:
|
|
46
|
+
"""Hanzo Tasks client for submitting and managing workflows."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, temporal: TemporalClient) -> None:
|
|
49
|
+
self._temporal = temporal
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
async def connect(cls, config: TasksConfig | None = None) -> Client:
|
|
53
|
+
"""Connect to Temporal server."""
|
|
54
|
+
cfg = config or TasksConfig()
|
|
55
|
+
temporal = await TemporalClient.connect(cfg.address, namespace=cfg.namespace)
|
|
56
|
+
return cls(temporal)
|
|
57
|
+
|
|
58
|
+
async def submit(
|
|
59
|
+
self,
|
|
60
|
+
workflow: Any,
|
|
61
|
+
input: Any,
|
|
62
|
+
*,
|
|
63
|
+
id: str | None = None,
|
|
64
|
+
queue: str = "default",
|
|
65
|
+
**kwargs: Any,
|
|
66
|
+
) -> WorkflowHandle:
|
|
67
|
+
"""Submit a workflow for execution."""
|
|
68
|
+
handle = await self._temporal.start_workflow(
|
|
69
|
+
workflow,
|
|
70
|
+
input,
|
|
71
|
+
id=id or str(uuid4()),
|
|
72
|
+
task_queue=queue,
|
|
73
|
+
**kwargs,
|
|
74
|
+
)
|
|
75
|
+
return WorkflowHandle(handle)
|
|
76
|
+
|
|
77
|
+
async def get_result(self, workflow_id: str, result_type: type | None = None) -> Any:
|
|
78
|
+
"""Get the result of a completed workflow."""
|
|
79
|
+
handle = self._temporal.get_workflow_handle(workflow_id)
|
|
80
|
+
return await handle.result(result_type=result_type)
|
|
81
|
+
|
|
82
|
+
async def cancel(self, workflow_id: str) -> None:
|
|
83
|
+
"""Cancel a running workflow."""
|
|
84
|
+
handle = self._temporal.get_workflow_handle(workflow_id)
|
|
85
|
+
await handle.cancel()
|
|
86
|
+
|
|
87
|
+
async def signal(self, workflow_id: str, signal_name: str, data: Any = None) -> None:
|
|
88
|
+
"""Send a signal to a running workflow."""
|
|
89
|
+
handle = self._temporal.get_workflow_handle(workflow_id)
|
|
90
|
+
await handle.signal(signal_name, data)
|
|
91
|
+
|
|
92
|
+
async def query(self, workflow_id: str, query_name: str) -> Any:
|
|
93
|
+
"""Query a running workflow."""
|
|
94
|
+
handle = self._temporal.get_workflow_handle(workflow_id)
|
|
95
|
+
return await handle.query(query_name)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def temporal(self) -> TemporalClient:
|
|
99
|
+
"""Access the underlying Temporal client."""
|
|
100
|
+
return self._temporal
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Hanzo Tasks worker — polls and executes activities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from temporalio.worker import Worker as TemporalWorker
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Worker:
|
|
11
|
+
"""Hanzo Tasks worker that polls a queue and executes workflows/activities."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
client: Any,
|
|
16
|
+
queue: str = "default",
|
|
17
|
+
workflows: list[Any] | None = None,
|
|
18
|
+
activities: list[Any] | None = None,
|
|
19
|
+
) -> None:
|
|
20
|
+
self._client = client
|
|
21
|
+
self._queue = queue
|
|
22
|
+
self._workflows: list[Any] = workflows or []
|
|
23
|
+
self._activities: list[Any] = activities or []
|
|
24
|
+
self._worker: TemporalWorker | None = None
|
|
25
|
+
|
|
26
|
+
def register_workflow(self, workflow_cls: Any) -> Any:
|
|
27
|
+
"""Register a workflow class. Can be used as a decorator."""
|
|
28
|
+
self._workflows.append(workflow_cls)
|
|
29
|
+
return workflow_cls
|
|
30
|
+
|
|
31
|
+
def register_activity(self, activity_fn: Any) -> Any:
|
|
32
|
+
"""Register an activity function. Can be used as a decorator."""
|
|
33
|
+
self._activities.append(activity_fn)
|
|
34
|
+
return activity_fn
|
|
35
|
+
|
|
36
|
+
async def run(self) -> None:
|
|
37
|
+
"""Start the worker. Blocks until shutdown."""
|
|
38
|
+
temporal_client = (
|
|
39
|
+
self._client.temporal
|
|
40
|
+
if hasattr(self._client, "temporal")
|
|
41
|
+
else self._client
|
|
42
|
+
)
|
|
43
|
+
self._worker = TemporalWorker(
|
|
44
|
+
temporal_client,
|
|
45
|
+
task_queue=self._queue,
|
|
46
|
+
workflows=self._workflows,
|
|
47
|
+
activities=self._activities,
|
|
48
|
+
)
|
|
49
|
+
await self._worker.run()
|
|
50
|
+
|
|
51
|
+
async def shutdown(self) -> None:
|
|
52
|
+
"""Gracefully shutdown the worker."""
|
|
53
|
+
if self._worker:
|
|
54
|
+
await self._worker.shutdown()
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Pre-built workflows for agent task orchestration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
|
|
8
|
+
from temporalio import workflow
|
|
9
|
+
from temporalio.common import RetryPolicy
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class AgentTaskInput:
|
|
14
|
+
"""Input for a single agent task."""
|
|
15
|
+
|
|
16
|
+
space_id: str
|
|
17
|
+
agent_id: str
|
|
18
|
+
task_title: str
|
|
19
|
+
task_prompt: str
|
|
20
|
+
timeout_seconds: int = 3600
|
|
21
|
+
max_retries: int = 3
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class AgentTaskOutput:
|
|
26
|
+
"""Output from an agent task execution."""
|
|
27
|
+
|
|
28
|
+
result: str = ""
|
|
29
|
+
error: str = ""
|
|
30
|
+
elapsed_seconds: float = 0.0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@workflow.defn
|
|
34
|
+
class AgentTaskWorkflow:
|
|
35
|
+
"""Execute a single agent task with retries and timeout."""
|
|
36
|
+
|
|
37
|
+
@workflow.run
|
|
38
|
+
async def run(self, input: AgentTaskInput) -> AgentTaskOutput:
|
|
39
|
+
return await workflow.execute_activity(
|
|
40
|
+
"execute_agent_task",
|
|
41
|
+
input,
|
|
42
|
+
start_to_close_timeout=timedelta(seconds=input.timeout_seconds),
|
|
43
|
+
retry_policy=RetryPolicy(maximum_attempts=input.max_retries),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@workflow.defn
|
|
48
|
+
class PipelineWorkflow:
|
|
49
|
+
"""Run agent tasks sequentially (pipeline)."""
|
|
50
|
+
|
|
51
|
+
@workflow.run
|
|
52
|
+
async def run(self, tasks: list[AgentTaskInput]) -> list[AgentTaskOutput]:
|
|
53
|
+
results: list[AgentTaskOutput] = []
|
|
54
|
+
for task in tasks:
|
|
55
|
+
result = await workflow.execute_activity(
|
|
56
|
+
"execute_agent_task",
|
|
57
|
+
task,
|
|
58
|
+
start_to_close_timeout=timedelta(seconds=task.timeout_seconds),
|
|
59
|
+
retry_policy=RetryPolicy(maximum_attempts=task.max_retries),
|
|
60
|
+
)
|
|
61
|
+
results.append(result)
|
|
62
|
+
return results
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@workflow.defn
|
|
66
|
+
class FanOutWorkflow:
|
|
67
|
+
"""Run agent tasks in parallel (fan-out/fan-in)."""
|
|
68
|
+
|
|
69
|
+
@workflow.run
|
|
70
|
+
async def run(self, tasks: list[AgentTaskInput]) -> list[AgentTaskOutput]:
|
|
71
|
+
handles = []
|
|
72
|
+
for task in tasks:
|
|
73
|
+
handle = workflow.start_activity(
|
|
74
|
+
"execute_agent_task",
|
|
75
|
+
task,
|
|
76
|
+
start_to_close_timeout=timedelta(seconds=task.timeout_seconds),
|
|
77
|
+
retry_policy=RetryPolicy(maximum_attempts=task.max_retries),
|
|
78
|
+
)
|
|
79
|
+
handles.append(handle)
|
|
80
|
+
return [await h for h in handles]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hanzo-tasks
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Hanzo Tasks SDK — Durable workflow execution for AI agents (powered by Temporal)
|
|
5
|
+
Author-email: Hanzo AI <dev@hanzo.ai>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/hanzoai/python-sdk
|
|
8
|
+
Project-URL: Repository, https://github.com/hanzoai/python-sdk/tree/main/pkg/hanzo-tasks
|
|
9
|
+
Project-URL: Documentation, https://hanzo.ai/docs/tasks
|
|
10
|
+
Keywords: hanzo,temporal,tasks,workflow,durable,agents
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.12
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Requires-Dist: temporalio>=1.9.0
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest-asyncio>=0.26.0; extra == "dev"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
hanzo_tasks/__init__.py
|
|
3
|
+
hanzo_tasks/activities.py
|
|
4
|
+
hanzo_tasks/client.py
|
|
5
|
+
hanzo_tasks/py.typed
|
|
6
|
+
hanzo_tasks/worker.py
|
|
7
|
+
hanzo_tasks/workflows.py
|
|
8
|
+
hanzo_tasks.egg-info/PKG-INFO
|
|
9
|
+
hanzo_tasks.egg-info/SOURCES.txt
|
|
10
|
+
hanzo_tasks.egg-info/dependency_links.txt
|
|
11
|
+
hanzo_tasks.egg-info/requires.txt
|
|
12
|
+
hanzo_tasks.egg-info/top_level.txt
|
|
13
|
+
tests/test_tasks.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hanzo_tasks
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "hanzo-tasks"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Hanzo Tasks SDK — Durable workflow execution for AI agents (powered by Temporal)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Hanzo AI", email = "dev@hanzo.ai" }]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
keywords = ["hanzo", "temporal", "tasks", "workflow", "durable", "agents"]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"temporalio>=1.9.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/hanzoai/python-sdk"
|
|
29
|
+
Repository = "https://github.com/hanzoai/python-sdk/tree/main/pkg/hanzo-tasks"
|
|
30
|
+
Documentation = "https://hanzo.ai/docs/tasks"
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
dev = [
|
|
34
|
+
"pytest>=7.0.0",
|
|
35
|
+
"pytest-asyncio>=0.26.0",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[tool.setuptools.packages.find]
|
|
39
|
+
where = ["."]
|
|
40
|
+
include = ["hanzo_tasks*"]
|
|
41
|
+
|
|
42
|
+
[tool.setuptools.package-data]
|
|
43
|
+
hanzo_tasks = ["py.typed"]
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""Hanzo Tasks test suite.
|
|
2
|
+
|
|
3
|
+
Unit tests for client config, worker registration, dataclasses,
|
|
4
|
+
workflow decorators, and activity executor wiring. No Temporal
|
|
5
|
+
server required.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import inspect
|
|
9
|
+
from dataclasses import asdict
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
import pytest_asyncio
|
|
13
|
+
|
|
14
|
+
from hanzo_tasks import (
|
|
15
|
+
AgentTaskInput,
|
|
16
|
+
AgentTaskOutput,
|
|
17
|
+
AgentTaskWorkflow,
|
|
18
|
+
Client,
|
|
19
|
+
FanOutWorkflow,
|
|
20
|
+
PipelineWorkflow,
|
|
21
|
+
TasksConfig,
|
|
22
|
+
Worker,
|
|
23
|
+
WorkflowHandle,
|
|
24
|
+
execute_agent_task,
|
|
25
|
+
set_agent_executor,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# -- config ------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TestTasksConfig:
|
|
33
|
+
def test_defaults(self):
|
|
34
|
+
cfg = TasksConfig()
|
|
35
|
+
assert cfg.address == "localhost:7233"
|
|
36
|
+
assert cfg.namespace == "hanzo"
|
|
37
|
+
assert cfg.tls is False
|
|
38
|
+
|
|
39
|
+
def test_custom(self):
|
|
40
|
+
cfg = TasksConfig(address="temporal.prod:7233", namespace="prod", tls=True)
|
|
41
|
+
assert cfg.address == "temporal.prod:7233"
|
|
42
|
+
assert cfg.namespace == "prod"
|
|
43
|
+
assert cfg.tls is True
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# -- dataclasses -------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TestAgentTaskInput:
|
|
50
|
+
def test_defaults(self):
|
|
51
|
+
inp = AgentTaskInput(
|
|
52
|
+
space_id="sp-1",
|
|
53
|
+
agent_id="ag-1",
|
|
54
|
+
task_title="Fix bug",
|
|
55
|
+
task_prompt="Fix the null pointer in main.go",
|
|
56
|
+
)
|
|
57
|
+
assert inp.space_id == "sp-1"
|
|
58
|
+
assert inp.agent_id == "ag-1"
|
|
59
|
+
assert inp.task_title == "Fix bug"
|
|
60
|
+
assert inp.task_prompt == "Fix the null pointer in main.go"
|
|
61
|
+
assert inp.timeout_seconds == 3600
|
|
62
|
+
assert inp.max_retries == 3
|
|
63
|
+
|
|
64
|
+
def test_custom_timeout(self):
|
|
65
|
+
inp = AgentTaskInput(
|
|
66
|
+
space_id="sp-2",
|
|
67
|
+
agent_id="ag-2",
|
|
68
|
+
task_title="Deploy",
|
|
69
|
+
task_prompt="Deploy to prod",
|
|
70
|
+
timeout_seconds=600,
|
|
71
|
+
max_retries=1,
|
|
72
|
+
)
|
|
73
|
+
assert inp.timeout_seconds == 600
|
|
74
|
+
assert inp.max_retries == 1
|
|
75
|
+
|
|
76
|
+
def test_serializes_to_dict(self):
|
|
77
|
+
inp = AgentTaskInput(
|
|
78
|
+
space_id="sp-1",
|
|
79
|
+
agent_id="ag-1",
|
|
80
|
+
task_title="T",
|
|
81
|
+
task_prompt="P",
|
|
82
|
+
)
|
|
83
|
+
d = asdict(inp)
|
|
84
|
+
assert d["space_id"] == "sp-1"
|
|
85
|
+
assert d["agent_id"] == "ag-1"
|
|
86
|
+
assert d["task_title"] == "T"
|
|
87
|
+
assert d["task_prompt"] == "P"
|
|
88
|
+
assert d["timeout_seconds"] == 3600
|
|
89
|
+
assert d["max_retries"] == 3
|
|
90
|
+
assert len(d) == 6
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class TestAgentTaskOutput:
|
|
94
|
+
def test_defaults(self):
|
|
95
|
+
out = AgentTaskOutput()
|
|
96
|
+
assert out.result == ""
|
|
97
|
+
assert out.error == ""
|
|
98
|
+
assert out.elapsed_seconds == 0.0
|
|
99
|
+
|
|
100
|
+
def test_success(self):
|
|
101
|
+
out = AgentTaskOutput(result="done", elapsed_seconds=1.5)
|
|
102
|
+
assert out.result == "done"
|
|
103
|
+
assert out.error == ""
|
|
104
|
+
assert out.elapsed_seconds == 1.5
|
|
105
|
+
|
|
106
|
+
def test_error(self):
|
|
107
|
+
out = AgentTaskOutput(error="timeout", elapsed_seconds=3600.0)
|
|
108
|
+
assert out.error == "timeout"
|
|
109
|
+
assert out.result == ""
|
|
110
|
+
|
|
111
|
+
def test_serializes_to_dict(self):
|
|
112
|
+
out = AgentTaskOutput(result="ok", elapsed_seconds=0.1)
|
|
113
|
+
d = asdict(out)
|
|
114
|
+
assert d == {"result": "ok", "error": "", "elapsed_seconds": 0.1}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# -- workflow decorators -----------------------------------------------------
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class TestWorkflowDecorators:
|
|
121
|
+
def test_agent_task_workflow_has_run(self):
|
|
122
|
+
assert hasattr(AgentTaskWorkflow, "run")
|
|
123
|
+
assert inspect.iscoroutinefunction(AgentTaskWorkflow.run)
|
|
124
|
+
|
|
125
|
+
def test_pipeline_workflow_has_run(self):
|
|
126
|
+
assert hasattr(PipelineWorkflow, "run")
|
|
127
|
+
assert inspect.iscoroutinefunction(PipelineWorkflow.run)
|
|
128
|
+
|
|
129
|
+
def test_fanout_workflow_has_run(self):
|
|
130
|
+
assert hasattr(FanOutWorkflow, "run")
|
|
131
|
+
assert inspect.iscoroutinefunction(FanOutWorkflow.run)
|
|
132
|
+
|
|
133
|
+
def test_workflow_classes_are_distinct(self):
|
|
134
|
+
assert AgentTaskWorkflow is not PipelineWorkflow
|
|
135
|
+
assert PipelineWorkflow is not FanOutWorkflow
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# -- worker ------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class TestWorker:
|
|
142
|
+
def test_init_defaults(self):
|
|
143
|
+
w = Worker(client=None, queue="test-q")
|
|
144
|
+
assert w._queue == "test-q"
|
|
145
|
+
assert w._workflows == []
|
|
146
|
+
assert w._activities == []
|
|
147
|
+
assert w._worker is None
|
|
148
|
+
|
|
149
|
+
def test_register_workflow(self):
|
|
150
|
+
w = Worker(client=None)
|
|
151
|
+
|
|
152
|
+
class MyWorkflow:
|
|
153
|
+
pass
|
|
154
|
+
|
|
155
|
+
result = w.register_workflow(MyWorkflow)
|
|
156
|
+
assert result is MyWorkflow
|
|
157
|
+
assert MyWorkflow in w._workflows
|
|
158
|
+
|
|
159
|
+
def test_register_activity(self):
|
|
160
|
+
w = Worker(client=None)
|
|
161
|
+
|
|
162
|
+
async def my_activity(input):
|
|
163
|
+
return "ok"
|
|
164
|
+
|
|
165
|
+
result = w.register_activity(my_activity)
|
|
166
|
+
assert result is my_activity
|
|
167
|
+
assert my_activity in w._activities
|
|
168
|
+
|
|
169
|
+
def test_register_multiple(self):
|
|
170
|
+
w = Worker(client=None)
|
|
171
|
+
for i in range(5):
|
|
172
|
+
w.register_workflow(type(f"Wf{i}", (), {}))
|
|
173
|
+
assert len(w._workflows) == 5
|
|
174
|
+
|
|
175
|
+
def test_init_with_preloaded(self):
|
|
176
|
+
workflows = [AgentTaskWorkflow, PipelineWorkflow]
|
|
177
|
+
activities = [execute_agent_task]
|
|
178
|
+
w = Worker(client=None, workflows=workflows, activities=activities)
|
|
179
|
+
assert len(w._workflows) == 2
|
|
180
|
+
assert len(w._activities) == 1
|
|
181
|
+
|
|
182
|
+
def test_does_not_mutate_caller_list(self):
|
|
183
|
+
workflows: list = []
|
|
184
|
+
w = Worker(client=None, workflows=workflows)
|
|
185
|
+
w.register_workflow(AgentTaskWorkflow)
|
|
186
|
+
# The caller's original list should not be modified since we
|
|
187
|
+
# pass a new list via `or []`, but if caller passes a list,
|
|
188
|
+
# it IS the same reference. That's expected Python behavior.
|
|
189
|
+
# Just verify worker has the workflow.
|
|
190
|
+
assert AgentTaskWorkflow in w._workflows
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# -- executor wiring ---------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class TestSetAgentExecutor:
|
|
197
|
+
def test_set_and_reset(self):
|
|
198
|
+
import hanzo_tasks.activities as act
|
|
199
|
+
|
|
200
|
+
original = act._agent_executor
|
|
201
|
+
|
|
202
|
+
async def my_exec(input):
|
|
203
|
+
return "executed"
|
|
204
|
+
|
|
205
|
+
set_agent_executor(my_exec)
|
|
206
|
+
assert act._agent_executor is my_exec
|
|
207
|
+
|
|
208
|
+
# Restore
|
|
209
|
+
act._agent_executor = original
|
|
210
|
+
|
|
211
|
+
@pytest.mark.asyncio
|
|
212
|
+
async def test_execute_without_executor_raises(self):
|
|
213
|
+
import hanzo_tasks.activities as act
|
|
214
|
+
|
|
215
|
+
saved = act._agent_executor
|
|
216
|
+
act._agent_executor = None
|
|
217
|
+
try:
|
|
218
|
+
with pytest.raises(RuntimeError, match="No agent executor registered"):
|
|
219
|
+
await execute_agent_task(None)
|
|
220
|
+
finally:
|
|
221
|
+
act._agent_executor = saved
|
|
222
|
+
|
|
223
|
+
@pytest.mark.asyncio
|
|
224
|
+
async def test_execute_with_executor(self):
|
|
225
|
+
import hanzo_tasks.activities as act
|
|
226
|
+
|
|
227
|
+
saved = act._agent_executor
|
|
228
|
+
|
|
229
|
+
async def mock_exec(input):
|
|
230
|
+
return AgentTaskOutput(result=f"done:{input.task_title}", elapsed_seconds=0.01)
|
|
231
|
+
|
|
232
|
+
set_agent_executor(mock_exec)
|
|
233
|
+
try:
|
|
234
|
+
inp = AgentTaskInput(
|
|
235
|
+
space_id="sp-1",
|
|
236
|
+
agent_id="ag-1",
|
|
237
|
+
task_title="test",
|
|
238
|
+
task_prompt="do it",
|
|
239
|
+
)
|
|
240
|
+
out = await execute_agent_task(inp)
|
|
241
|
+
assert out.result == "done:test"
|
|
242
|
+
assert out.elapsed_seconds == 0.01
|
|
243
|
+
finally:
|
|
244
|
+
act._agent_executor = saved
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# -- workflow handle ---------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class TestWorkflowHandle:
|
|
251
|
+
def test_id(self):
|
|
252
|
+
class FakeHandle:
|
|
253
|
+
id = "wf-123"
|
|
254
|
+
result_run_id = "run-456"
|
|
255
|
+
|
|
256
|
+
h = WorkflowHandle(FakeHandle())
|
|
257
|
+
assert h.id == "wf-123"
|
|
258
|
+
assert h.run_id == "run-456"
|
|
259
|
+
|
|
260
|
+
@pytest.mark.asyncio
|
|
261
|
+
async def test_cancel(self):
|
|
262
|
+
cancelled = False
|
|
263
|
+
|
|
264
|
+
class FakeHandle:
|
|
265
|
+
id = "wf-1"
|
|
266
|
+
result_run_id = "run-1"
|
|
267
|
+
|
|
268
|
+
async def cancel(self):
|
|
269
|
+
nonlocal cancelled
|
|
270
|
+
cancelled = True
|
|
271
|
+
|
|
272
|
+
h = WorkflowHandle(FakeHandle())
|
|
273
|
+
await h.cancel()
|
|
274
|
+
assert cancelled
|
|
275
|
+
|
|
276
|
+
@pytest.mark.asyncio
|
|
277
|
+
async def test_signal(self):
|
|
278
|
+
signals = []
|
|
279
|
+
|
|
280
|
+
class FakeHandle:
|
|
281
|
+
id = "wf-1"
|
|
282
|
+
result_run_id = "run-1"
|
|
283
|
+
|
|
284
|
+
async def signal(self, name, data=None):
|
|
285
|
+
signals.append((name, data))
|
|
286
|
+
|
|
287
|
+
h = WorkflowHandle(FakeHandle())
|
|
288
|
+
await h.signal("pause", {"reason": "lunch"})
|
|
289
|
+
assert signals == [("pause", {"reason": "lunch"})]
|
|
290
|
+
|
|
291
|
+
@pytest.mark.asyncio
|
|
292
|
+
async def test_result(self):
|
|
293
|
+
class FakeHandle:
|
|
294
|
+
id = "wf-1"
|
|
295
|
+
result_run_id = "run-1"
|
|
296
|
+
|
|
297
|
+
async def result(self, result_type=None):
|
|
298
|
+
return "the-result"
|
|
299
|
+
|
|
300
|
+
h = WorkflowHandle(FakeHandle())
|
|
301
|
+
assert await h.result() == "the-result"
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# -- __init__ exports --------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class TestExports:
|
|
308
|
+
def test_version(self):
|
|
309
|
+
import hanzo_tasks
|
|
310
|
+
|
|
311
|
+
assert hanzo_tasks.__version__ == "0.1.0"
|
|
312
|
+
|
|
313
|
+
def test_all_exports_importable(self):
|
|
314
|
+
import hanzo_tasks
|
|
315
|
+
|
|
316
|
+
for name in hanzo_tasks.__all__:
|
|
317
|
+
assert hasattr(hanzo_tasks, name), f"{name} not found in hanzo_tasks"
|