nora-lib-impl 1.0.0__py3-none-any.whl

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.
@@ -0,0 +1,156 @@
1
+ """
2
+ Handles IO for asynchronous task-related state.
3
+
4
+ A StateManager instance must be initialized with
5
+ a concrete subclass of `AsyncTaskState`, as implemented
6
+ by dependent projects.
7
+ """
8
+
9
+ import logging
10
+ from uuid import UUID
11
+ from datetime import datetime, timezone
12
+ from typing import Optional, Any
13
+ from pydantic import BaseModel
14
+
15
+ from nora_lib.tasks.models import AsyncTaskState, R
16
+ from nora_lib.tasks.state import (
17
+ IStateManager,
18
+ TaskStateFetchException,
19
+ NoSuchTaskException,
20
+ )
21
+ from nora_lib.impl.interactions.interactions_service import InteractionsService
22
+ from nora_lib.impl.interactions.models import Event, ReturnedEvent
23
+ from nora_lib.impl.pubsub import PubsubService
24
+ from nora_lib.impl.context.agent_context import AgentContext
25
+
26
+ TASK_STATE_CHANGE_TOPIC = "istore:event:task_state"
27
+
28
+
29
+ class RemoteStateManagerFactory:
30
+ """
31
+ Stores task state in the interaction store
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ agent_name: str,
37
+ actor_id: UUID,
38
+ interactions_service: InteractionsService,
39
+ pubsub_service: PubsubService,
40
+ ):
41
+ """
42
+ :param agent_name: Used to form the event type that will hold the task state in the interactions store
43
+ :param actor_id: Associated with the events written to the interactions store
44
+ :param interactions_service:
45
+ """
46
+ self.agent_name = agent_name
47
+ self.actor_id = actor_id
48
+ self.interactions_service = interactions_service
49
+ self.pubsub_service = pubsub_service
50
+
51
+ def for_message(self, message_id: str) -> IStateManager[R]:
52
+ return RemoteStateManager(
53
+ self.agent_name,
54
+ self.actor_id,
55
+ self.interactions_service,
56
+ self.pubsub_service,
57
+ message_id,
58
+ )
59
+
60
+ def for_agent_context(self, context: AgentContext) -> IStateManager[R]:
61
+ return RemoteStateManager(
62
+ self.agent_name,
63
+ self.actor_id,
64
+ self.interactions_service,
65
+ PubsubService(context.pubsub.base_url, context.pubsub.namespace),
66
+ context.message.message_id,
67
+ )
68
+
69
+
70
+ class RemoteStateManager(IStateManager[R]):
71
+ """
72
+ Stores task state in the interaction store
73
+ """
74
+
75
+ _TASK_STATE_EVENT_TYPE = "agent:{}:task_state"
76
+
77
+ def __init__(
78
+ self,
79
+ agent_name: str,
80
+ actor_id: UUID,
81
+ interactions_service: InteractionsService,
82
+ pubsub_service: PubsubService,
83
+ message_id: str,
84
+ ):
85
+ """
86
+ :param agent_name: Agent that saved the task
87
+ :param actor_id: ID for the agent (ignored when reading)
88
+ :param message_id: The message that initiated the request for task status
89
+ """
90
+ self.agent_name = agent_name
91
+ self.actor_id = actor_id
92
+ self.message_id = message_id
93
+ self.interactions_service = interactions_service
94
+ self.pubsub_service = pubsub_service
95
+
96
+ def read_state(self, task_id: str) -> AsyncTaskState[R]:
97
+ event_type = RemoteStateManager._TASK_STATE_EVENT_TYPE.format(self.agent_name)
98
+ response = (
99
+ self.interactions_service.fetch_thread_messages_and_events_for_message(
100
+ self.message_id, [event_type]
101
+ )
102
+ )
103
+ latest_state: Optional[AsyncTaskState[R]] = None
104
+ latest_timestamp = None
105
+ for msg in response.messages or []:
106
+ for event in msg.events or []:
107
+ try:
108
+ state = AsyncTaskState[Any].model_validate(event.data)
109
+ except Exception as e:
110
+ # Event json blob has unexpected format
111
+ raise TaskStateFetchException(
112
+ f"Event of type {event_type} for message {self.message_id} does not deserialize to AsyncTaskState: {e}"
113
+ )
114
+ if state.task_id != task_id:
115
+ continue
116
+ if latest_state is None or (
117
+ latest_timestamp and event.timestamp > latest_timestamp
118
+ ):
119
+ latest_state = state
120
+ latest_timestamp = event.timestamp
121
+
122
+ if not latest_state:
123
+ raise NoSuchTaskException(task_id)
124
+ return latest_state
125
+
126
+ def write_state(self, state: AsyncTaskState[R]) -> None:
127
+ event_type = RemoteStateManager._TASK_STATE_EVENT_TYPE.format(self.agent_name)
128
+ event = Event(
129
+ type=event_type,
130
+ actor_id=self.actor_id,
131
+ timestamp=datetime.now(tz=timezone.utc),
132
+ message_id=self.message_id,
133
+ data=state.model_dump(),
134
+ )
135
+ event_id = self.interactions_service.save_event(event)
136
+ returned_event = ReturnedEvent(
137
+ event_id=event_id,
138
+ type=event.type,
139
+ actor_id=event.actor_id,
140
+ message_id=event.message_id,
141
+ timestamp=event.timestamp,
142
+ )
143
+ payload = TaskStateChangeNotification(
144
+ agent=self.agent_name, event=returned_event
145
+ )
146
+ try:
147
+ self.pubsub_service.publish(TASK_STATE_CHANGE_TOPIC, payload.model_dump())
148
+ except Exception as e:
149
+ logging.exception(
150
+ f"Failed to publish event to pubsub topic {TASK_STATE_CHANGE_TOPIC} at {self.pubsub_service.base_url}"
151
+ )
152
+
153
+
154
+ class TaskStateChangeNotification(BaseModel):
155
+ agent: str
156
+ event: ReturnedEvent
File without changes
@@ -0,0 +1,47 @@
1
+ from enum import Enum
2
+ import uuid
3
+ from typing import Optional
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from nora_lib.serializers import (
8
+ UuidWithSerializer,
9
+ DatetimeWithSerializer,
10
+ )
11
+
12
+
13
+ class RunState(str, Enum):
14
+ """State of a step"""
15
+
16
+ CREATED = "created"
17
+ RUNNING = "running"
18
+ SUCCEEDED = "succeeded"
19
+ FAILED = "failed"
20
+
21
+
22
+ class StepProgress(BaseModel):
23
+ """Data class for step progress. This goes into the `data` field in `Event`."""
24
+
25
+ # A short message, e.g. "searching for $query". Recommend < 100 chars.
26
+ short_desc: str
27
+ # Detailed message.
28
+ long_desc: Optional[str] = None
29
+ # Updates on the same unit of work have the same step_id.
30
+ step_id: UuidWithSerializer = Field(default_factory=uuid.uuid4)
31
+ # Inner steps can be constituent to some outer step, effectively a tree.
32
+ parent_step_id: Optional[UuidWithSerializer] = None
33
+ # Populated if this step is due to an async task.
34
+ task_id: Optional[str] = None
35
+
36
+ # Enum of possible states.
37
+ run_state: RunState = RunState.CREATED
38
+ # DB timestamp when this step was defined/created.
39
+ created_at: Optional[DatetimeWithSerializer] = None
40
+ # When this step started running.
41
+ started_at: Optional[DatetimeWithSerializer] = None
42
+ # Estimated finish time, if available.
43
+ finish_est: Optional[DatetimeWithSerializer] = None
44
+ # When this step stopped running, whether that was due to success or failure.
45
+ finished_at: Optional[DatetimeWithSerializer] = None
46
+ # Error message in case of terminal step failure.
47
+ error_message: Optional[str] = None
@@ -0,0 +1,122 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Optional
3
+ from datetime import datetime, timezone
4
+
5
+ from nora_lib.progress.models import StepProgress, RunState
6
+
7
+
8
+ class StepProgressWriter(ABC):
9
+ """Writes step progress to a store or service"""
10
+
11
+ def write(self, step_progress: StepProgress):
12
+ """Default no-op implementation"""
13
+ pass
14
+
15
+
16
+ class StepProgressReporter:
17
+ """
18
+ Tracks the lifecycle of a task and records incremental progress to some external store
19
+
20
+ Usage:
21
+ # Create/define a step
22
+ find_papers_progress = StepProgressReporter(...)
23
+ find_papers_progress.create()
24
+
25
+ # Do something
26
+ ...
27
+
28
+ # Start the step
29
+ find_papers_progress.start()
30
+
31
+ # Describe a part of the work of that outer step as a child step.
32
+ ...
33
+ count_citation_progress = find_papers_progress.create_child_step(short_desc="Count citations")
34
+ count_citation_progress.create()
35
+ ...
36
+ count_citation_progress.start()
37
+ ...
38
+ count_citation_progress.finish(is_success=True)
39
+ ...
40
+
41
+ # Finish the outer step
42
+ find_papers_progress.finish(is_success=False, error_message="Something went wrong")
43
+
44
+ # Alternatively, you can use this as a context. Step state transitions are managed for you,
45
+ # so you should NOT call any of the create/start/finish methods.
46
+
47
+ with StepProgressReporter(...) as spr:
48
+ # Do something
49
+ ...
50
+
51
+ # This step will be automatically created, started, and finished when the context exits.
52
+ # If an exception is raised, the step will be marked as failed
53
+ # and the exception message will be recorded in the error_message field
54
+ """
55
+
56
+ def __init__(self, step_progress: StepProgress, writer: StepProgressWriter):
57
+ self.step_progress = step_progress
58
+ self.writer = writer
59
+
60
+ def __enter__(self):
61
+ if self.step_progress.created_at is None:
62
+ self.create()
63
+ self.start()
64
+ return self
65
+
66
+ def __exit__(self, error_type, value, traceback):
67
+ is_success = error_type is None
68
+ self.finish(is_success=is_success, error_message=str(value))
69
+ return True
70
+
71
+ def create(self):
72
+ """Create a step, but don't start it yet. This is useful for defining plans."""
73
+ if self.step_progress.run_state in [
74
+ RunState.RUNNING,
75
+ RunState.SUCCEEDED,
76
+ RunState.FAILED,
77
+ ]:
78
+ return
79
+
80
+ self.step_progress.run_state = RunState.CREATED
81
+ self.step_progress.created_at = datetime.now(timezone.utc)
82
+ self.writer.write(self.step_progress)
83
+
84
+ def start(self):
85
+ """Start a step"""
86
+ if self.step_progress.run_state in [
87
+ RunState.RUNNING,
88
+ RunState.SUCCEEDED,
89
+ RunState.FAILED,
90
+ ]:
91
+ return
92
+
93
+ self.step_progress.started_at = datetime.now(timezone.utc)
94
+ self.step_progress.run_state = RunState.RUNNING
95
+ self.writer.write(self.step_progress)
96
+
97
+ def finish(self, is_success: bool, error_message: Optional[str] = None):
98
+ """Finish a step whether it was successful or not"""
99
+ if self.step_progress.run_state != RunState.RUNNING:
100
+ return
101
+ else:
102
+ self.step_progress.finished_at = datetime.now(timezone.utc)
103
+ self.step_progress.run_state = (
104
+ RunState.SUCCEEDED if is_success else RunState.FAILED
105
+ )
106
+ self.step_progress.error_message = error_message if error_message else None
107
+ self.writer.write(self.step_progress)
108
+
109
+ def create_child_step(
110
+ self, short_desc: str, **step_progress_kwargs
111
+ ) -> "StepProgressReporter":
112
+ """Create a child step"""
113
+ child_step_progress = StepProgress(
114
+ short_desc=short_desc,
115
+ parent_step_id=self.step_progress.step_id,
116
+ task_id=self.step_progress.task_id,
117
+ **step_progress_kwargs,
118
+ )
119
+ child_reporter = StepProgressReporter(
120
+ step_progress=child_step_progress, writer=self.writer
121
+ )
122
+ return child_reporter
nora_lib/py.typed ADDED
File without changes
@@ -0,0 +1,10 @@
1
+ from datetime import datetime
2
+ from uuid import UUID
3
+
4
+ from pydantic import PlainSerializer
5
+ from typing_extensions import Annotated
6
+
7
+ UuidWithSerializer = Annotated[UUID, PlainSerializer(lambda x: str(x), return_type=str)]
8
+ DatetimeWithSerializer = Annotated[
9
+ datetime, PlainSerializer(lambda x: x.isoformat(), return_type=str)
10
+ ]
File without changes
@@ -0,0 +1,25 @@
1
+ from typing import Any, Dict, Generic, Optional, TypeVar
2
+ from pydantic import BaseModel, Field
3
+
4
+
5
+ R = TypeVar("R", bound=BaseModel)
6
+
7
+
8
+ TASK_STATUSES = dict(STARTED="STARTED", FAILED="FAILED", COMPLETED="COMPLETED")
9
+
10
+
11
+ class AsyncTaskState(BaseModel, Generic[R]):
12
+ """Models the current state of an asynchronous request."""
13
+
14
+ task_id: str = Field(
15
+ "Identifies the long-running task so that its status and eventual result"
16
+ "can be checked in follow-up calls."
17
+ )
18
+ estimated_time: str = Field(
19
+ description="How long we expect this task to take from start to finish."
20
+ )
21
+ task_status: str = Field(description="Current human-readable status of the task.")
22
+ task_result: Optional[R] = Field(description="Final result of the task.")
23
+ extra_state: Dict[str, Any] = Field(
24
+ description="Any extra task-specific state can go in here as free-form JSON-serializable dictionary."
25
+ )
@@ -0,0 +1,71 @@
1
+ """
2
+ Handles IO for asynchronous task-related state.
3
+
4
+ A StateManager instance must be initialized with
5
+ a concrete subclass of `AsyncTaskState`, as implemented
6
+ by dependent projects.
7
+ """
8
+
9
+ import json
10
+ import os
11
+ from typing import Generic, Type
12
+ from abc import ABC, abstractmethod
13
+
14
+ from nora_lib.tasks.models import AsyncTaskState, R, TASK_STATUSES
15
+
16
+
17
+ class NoSuchTaskException(Exception):
18
+ def __init__(self, task_id: str):
19
+ self._task_id = task_id
20
+
21
+ def __str__(self):
22
+ return f"No record found for task {self._task_id}"
23
+
24
+
25
+ class IStateManager(ABC, Generic[R]):
26
+ @abstractmethod
27
+ def read_state(self, task_id: str) -> AsyncTaskState[R]:
28
+ pass
29
+
30
+ @abstractmethod
31
+ def write_state(self, state: AsyncTaskState[R]) -> None:
32
+ pass
33
+
34
+ def update_status(self, task_id: str, new_status: str) -> None:
35
+ state = self.read_state(task_id)
36
+ state.task_status = new_status
37
+ self.write_state(state)
38
+
39
+ def save_result(self, task_id: str, task_result: R) -> None:
40
+ state = self.read_state(task_id)
41
+ state.task_status = TASK_STATUSES["COMPLETED"]
42
+ state.task_result = task_result
43
+ self.write_state(state)
44
+
45
+
46
+ class StateManager(IStateManager[R]):
47
+ """
48
+ Stores task state on local disk
49
+ """
50
+
51
+ def __init__(self, task_state_class: Type[AsyncTaskState[R]], state_dir) -> None:
52
+ self._task_state_class = task_state_class
53
+ self._state_dir = state_dir
54
+
55
+ def read_state(self, task_id: str) -> AsyncTaskState[R]:
56
+ task_state_path = os.path.join(self._state_dir, f"{task_id}.json")
57
+ if not os.path.isfile(task_state_path):
58
+ raise NoSuchTaskException(task_id)
59
+
60
+ with open(task_state_path, "r") as f:
61
+ return self._task_state_class(**json.loads(f.read()))
62
+
63
+ def write_state(self, state: AsyncTaskState[R]) -> None:
64
+ task_state_path = os.path.join(self._state_dir, f"{state.task_id}.json")
65
+ with open(task_state_path, "w") as f:
66
+ json.dump(state.model_dump(), f)
67
+
68
+
69
+ class TaskStateFetchException(Exception):
70
+ def __init__(self, message: str):
71
+ super().__init__(message)
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.2
2
+ Name: nora_lib-impl
3
+ Version: 1.0.0
4
+ Summary: For making and coordinating agents and tools
5
+ Home-page: https://github.com/allenai/nora_lib/impl
6
+ Requires-Python: >=3.9
7
+ Requires-Dist: pydantic<3,>=2
8
+ Requires-Dist: requests
9
+ Requires-Dist: boto3
10
+ Requires-Dist: aws_requests_auth
11
+ Provides-Extra: dev
12
+ Requires-Dist: mypy; extra == "dev"
13
+ Requires-Dist: pytest; extra == "dev"
14
+ Requires-Dist: black; extra == "dev"
15
+ Requires-Dist: types-requests; extra == "dev"
16
+ Dynamic: home-page
17
+ Dynamic: provides-extra
18
+ Dynamic: requires-dist
19
+ Dynamic: requires-python
20
+ Dynamic: summary
@@ -0,0 +1,25 @@
1
+ nora_lib/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ nora_lib/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ nora_lib/serializers.py,sha256=8l8pZWyVfVvtm2HjkvBwIrNRclFVmtuQZg16eD1LlNE,330
4
+ nora_lib/impl/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ nora_lib/impl/pubsub.py,sha256=0tmFfdOf4J-YZSxdpgXct0GpO9VatzqAfr7-5DB7s2Y,4779
6
+ nora_lib/impl/context/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ nora_lib/impl/context/agent_context.py,sha256=I6e24JGpDZTJaxrawx5FnkOVnpr1c2D5YTqs8zweRyU,864
8
+ nora_lib/impl/context/context_service.py,sha256=VfWBXDqGyR2mkv8sEVs4BFBtkmmjEM5oQSGUJFH2hi4,1189
9
+ nora_lib/impl/context/models.py,sha256=QQ4LnAemHL0N4YK9vm8DQIqFyRzaQFuX4Ngn_XKzUF0,356
10
+ nora_lib/impl/interactions/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ nora_lib/impl/interactions/interactions_service.py,sha256=0EJTTcRQeEbmC0CW-2v_J61cG-UQM34nFJc1s0ITqOM,19660
12
+ nora_lib/impl/interactions/models.py,sha256=rgZUft0yz2KpYVCkQizo9evN2cnsES3NPo8pzQEx2w4,12532
13
+ nora_lib/impl/interactions/step_progress.py,sha256=ZlxD488P7gHZdOOQhsFvk_bNJMRIyW912IWY9dTHyD8,2374
14
+ nora_lib/impl/tasks/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
+ nora_lib/impl/tasks/state.py,sha256=WJnmzqAzhqLzPbx1O6R7j1bbsqhjT5hwPb0CaUfNS_E,5374
16
+ nora_lib/progress/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
+ nora_lib/progress/models.py,sha256=YNwI_gFM7vzQBQaH6GIIQ28MXQDdL6m1_syFWvvMSWM,1581
18
+ nora_lib/progress/reporter.py,sha256=JK4taghGTUt0zWpEWAznGaxnNtQf4yEwnjT_wrtYQBI,4045
19
+ nora_lib/tasks/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
+ nora_lib/tasks/models.py,sha256=Guz4PC8DCrZ_JPOK6IvxHDNS1itTTwPmSMQHDsfbwdA,925
21
+ nora_lib/tasks/state.py,sha256=blgv4xxz3GCxd7KN9avbtAJaSvJB3lH-Cbcxywnr-gE,2142
22
+ nora_lib_impl-1.0.0.dist-info/METADATA,sha256=WPMniVqUGucqHt98vkRxGzQqr3hwdmXhTobMRtE9ZPI,579
23
+ nora_lib_impl-1.0.0.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
24
+ nora_lib_impl-1.0.0.dist-info/top_level.txt,sha256=BXasX5xI5NPFNqJsU63ojSi4Y3CPeH2ueaBR-WxxoaA,9
25
+ nora_lib_impl-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (75.8.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ nora_lib