nora-lib 0.0.5.dev3__tar.gz → 0.0.6.dev1__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.
Files changed (25) hide show
  1. {nora_lib-0.0.5.dev3 → nora_lib-0.0.6.dev1}/PKG-INFO +2 -1
  2. {nora_lib-0.0.5.dev3 → nora_lib-0.0.6.dev1}/nora_lib/interactions/interactions_service.py +73 -8
  3. {nora_lib-0.0.5.dev3 → nora_lib-0.0.6.dev1}/nora_lib/interactions/models.py +3 -3
  4. nora_lib-0.0.6.dev1/nora_lib/tasks/state.py +163 -0
  5. {nora_lib-0.0.5.dev3 → nora_lib-0.0.6.dev1}/nora_lib.egg-info/PKG-INFO +2 -1
  6. {nora_lib-0.0.5.dev3 → nora_lib-0.0.6.dev1}/nora_lib.egg-info/requires.txt +1 -0
  7. {nora_lib-0.0.5.dev3 → nora_lib-0.0.6.dev1}/setup.py +2 -2
  8. nora_lib-0.0.5.dev3/nora_lib/tasks/state.py +0 -53
  9. {nora_lib-0.0.5.dev3 → nora_lib-0.0.6.dev1}/README.md +0 -0
  10. {nora_lib-0.0.5.dev3 → nora_lib-0.0.6.dev1}/nora_lib/__init__.py +0 -0
  11. {nora_lib-0.0.5.dev3 → nora_lib-0.0.6.dev1}/nora_lib/context/__init__.py +0 -0
  12. {nora_lib-0.0.5.dev3 → nora_lib-0.0.6.dev1}/nora_lib/context/context_service.py +0 -0
  13. {nora_lib-0.0.5.dev3 → nora_lib-0.0.6.dev1}/nora_lib/context/models.py +0 -0
  14. {nora_lib-0.0.5.dev3 → nora_lib-0.0.6.dev1}/nora_lib/interactions/__init__.py +0 -0
  15. {nora_lib-0.0.5.dev3 → nora_lib-0.0.6.dev1}/nora_lib/py.typed +0 -0
  16. {nora_lib-0.0.5.dev3 → nora_lib-0.0.6.dev1}/nora_lib/tasks/__init__.py +0 -0
  17. {nora_lib-0.0.5.dev3 → nora_lib-0.0.6.dev1}/nora_lib/tasks/models.py +0 -0
  18. {nora_lib-0.0.5.dev3 → nora_lib-0.0.6.dev1}/nora_lib.egg-info/SOURCES.txt +0 -0
  19. {nora_lib-0.0.5.dev3 → nora_lib-0.0.6.dev1}/nora_lib.egg-info/dependency_links.txt +0 -0
  20. {nora_lib-0.0.5.dev3 → nora_lib-0.0.6.dev1}/nora_lib.egg-info/top_level.txt +0 -0
  21. {nora_lib-0.0.5.dev3 → nora_lib-0.0.6.dev1}/pyproject.toml +0 -0
  22. {nora_lib-0.0.5.dev3 → nora_lib-0.0.6.dev1}/setup.cfg +0 -0
  23. {nora_lib-0.0.5.dev3 → nora_lib-0.0.6.dev1}/tests/tasks/__init__.py +0 -0
  24. {nora_lib-0.0.5.dev3 → nora_lib-0.0.6.dev1}/tests/tasks/test_state.py +0 -0
  25. {nora_lib-0.0.5.dev3 → nora_lib-0.0.6.dev1}/tests/test_placeholder.py +0 -0
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: nora_lib
3
- Version: 0.0.5.dev3
3
+ Version: 0.0.6.dev1
4
4
  Summary: For making and coordinating agents and tools
5
5
  Home-page: https://github.com/allenai/nora_lib
6
6
  Requires-Python: >=3.9
7
7
  Requires-Dist: pydantic<3,>=2
8
8
  Requires-Dist: requests
9
+ Requires-Dist: boto3
9
10
  Provides-Extra: dev
10
11
  Requires-Dist: mypy; extra == "dev"
11
12
  Requires-Dist: pytest; extra == "dev"
@@ -2,6 +2,7 @@ from datetime import datetime
2
2
  import logging
3
3
  import requests
4
4
  from typing import List, Optional
5
+ import json
5
6
 
6
7
  from nora_lib.interactions.models import (
7
8
  AnnotationBatch,
@@ -31,7 +32,7 @@ class InteractionsService:
31
32
  message_url,
32
33
  json=message.model_dump(),
33
34
  headers=self.headers,
34
- timeout=int(self.timeout),
35
+ timeout=self.timeout,
35
36
  )
36
37
  response.raise_for_status()
37
38
 
@@ -42,7 +43,7 @@ class InteractionsService:
42
43
  event_url,
43
44
  json=event.model_dump(),
44
45
  headers=self.headers,
45
- timeout=int(self.timeout),
46
+ timeout=self.timeout,
46
47
  )
47
48
  response.raise_for_status()
48
49
 
@@ -53,7 +54,7 @@ class InteractionsService:
53
54
  annotation_url,
54
55
  json=annotation.model_dump(),
55
56
  headers=self.headers,
56
- timeout=int(self.timeout),
57
+ timeout=self.timeout,
57
58
  )
58
59
  response.raise_for_status()
59
60
 
@@ -68,7 +69,7 @@ class InteractionsService:
68
69
  message_url,
69
70
  json=request_body,
70
71
  headers=self.headers,
71
- timeout=int(self.timeout),
72
+ timeout=self.timeout,
72
73
  )
73
74
  response.raise_for_status()
74
75
  res_dict = response.json()["message"]
@@ -92,7 +93,7 @@ class InteractionsService:
92
93
  message_url,
93
94
  json=request_body,
94
95
  headers=self.headers,
95
- timeout=int(self.timeout),
96
+ timeout=self.timeout,
96
97
  )
97
98
  response.raise_for_status()
98
99
  return response.json()
@@ -143,7 +144,7 @@ class InteractionsService:
143
144
  message_url,
144
145
  json=request_body,
145
146
  headers=self.headers,
146
- timeout=int(self.timeout),
147
+ timeout=self.timeout,
147
148
  )
148
149
  response.raise_for_status()
149
150
  json_response = response.json()
@@ -176,11 +177,46 @@ class InteractionsService:
176
177
  thread_search_url,
177
178
  json=request_body,
178
179
  headers=self.headers,
179
- timeout=int(self.timeout),
180
+ timeout=self.timeout,
180
181
  )
181
182
  response.raise_for_status()
182
183
  return response.json()
183
184
 
185
+ def fetch_messages_and_events_for_forked_thread(
186
+ self, message_id: str, event_type: str
187
+ ) -> List[ReturnedMessage]:
188
+ """Build a history of messages for a given message including associated events.
189
+ This includes messages from pre-forked threads."""
190
+ returned_messages: List[ReturnedMessage] = []
191
+
192
+ messages_for_thread: ThreadRelationsResponse = (
193
+ self.fetch_thread_messages_and_events_for_message(message_id, [event_type])
194
+ )
195
+ if messages_for_thread.messages:
196
+ returned_messages.extend(messages_for_thread.messages)
197
+
198
+ # Lookup any thread_fork events (conversation across surfaces)
199
+ thread_fork_events = self.fetch_messages_and_events_for_thread(
200
+ messages_for_thread.thread_id, EventType.THREAD_FORK.value
201
+ )
202
+ for forked_thread_event in thread_fork_events.get("thread", {}).get(
203
+ "events", []
204
+ ):
205
+ event_data = ThreadForkEventData.model_validate(
206
+ forked_thread_event.get("data", {})
207
+ )
208
+ forked_thread: ThreadRelationsResponse = (
209
+ self.fetch_thread_messages_and_events_for_message(
210
+ event_data.previous_message_id, [event_type]
211
+ )
212
+ )
213
+ if forked_thread.messages:
214
+ returned_messages.extend(forked_thread.messages)
215
+
216
+ returned_messages.sort(key=lambda x: x.ts)
217
+
218
+ return returned_messages
219
+
184
220
  def fetch_events_for_message(
185
221
  self,
186
222
  message_id: str,
@@ -199,7 +235,7 @@ class InteractionsService:
199
235
  message_search_url,
200
236
  json=request_body,
201
237
  headers=self.headers,
202
- timeout=int(self.timeout),
238
+ timeout=self.timeout,
203
239
  )
204
240
  response.raise_for_status()
205
241
  return response.json()
@@ -237,3 +273,32 @@ class InteractionsService:
237
273
  }
238
274
  },
239
275
  }
276
+
277
+ @staticmethod
278
+ def prod() -> "InteractionsService":
279
+ return InteractionsService(
280
+ base_url="https://s2ub.prod.s2.allenai.org/service/noraretrieval",
281
+ timeout=30,
282
+ token=InteractionsService._fetch_bearer_token(
283
+ "nora/prod/interaction-bearer-token"
284
+ ),
285
+ )
286
+
287
+ @staticmethod
288
+ def dev() -> "InteractionsService":
289
+ return InteractionsService(
290
+ base_url="https://s2ub.dev.s2.allenai.org/service/noraretrieval",
291
+ timeout=30,
292
+ token=InteractionsService._fetch_bearer_token(
293
+ "nora/dev/interaction-bearer-token"
294
+ ),
295
+ )
296
+
297
+ @staticmethod
298
+ def _fetch_bearer_token(secret_id: str) -> str:
299
+ import boto3
300
+
301
+ secrets_manager = boto3.client("secretsmanager", region_name="us-west-2")
302
+ return json.loads(
303
+ secrets_manager.get_secret_value(SecretId=secret_id)["SecretString"]
304
+ )["token"]
@@ -43,7 +43,7 @@ class Message(BaseModel):
43
43
  channel_id: str
44
44
  surface: Surface
45
45
  ts: datetime
46
- annotations: Optional[List[Annotation]] = None
46
+ annotations: List[Annotation] = Field(default_factory=list)
47
47
 
48
48
  @field_serializer("actor_id")
49
49
  def serialize_actor_id(self, actor_id: UUID):
@@ -97,7 +97,7 @@ class ReturnedMessage(BaseModel):
97
97
  events: List[Event] = Field(default_factory=list)
98
98
  thread_id: Optional[str] = None
99
99
  channel_id: Optional[str] = None
100
- annotations: Optional[List[Annotation]] = None
100
+ annotations: List[Annotation] = Field(default_factory=list)
101
101
 
102
102
 
103
103
  class AgentMessageData(BaseModel):
@@ -127,7 +127,7 @@ class ReturnedAgentContextMessage(BaseModel):
127
127
  text: str
128
128
  ts: str
129
129
  annotated_text: Optional[str] = None
130
- events: Optional[List[ReturnedAgentContextEvent]] = None
130
+ events: List[ReturnedAgentContextEvent] = Field(default_factory=list)
131
131
 
132
132
 
133
133
  class ThreadForkEventData(BaseModel):
@@ -0,0 +1,163 @@
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 uuid import UUID
12
+ from datetime import datetime, timezone
13
+ from typing import Generic, Optional, Type, Any
14
+ from abc import ABC, abstractmethod
15
+
16
+ from nora_lib.tasks.models import AsyncTaskState, R, TASK_STATUSES
17
+ from nora_lib.interactions.interactions_service import InteractionsService
18
+ from nora_lib.interactions.models import Event
19
+
20
+
21
+ class NoSuchTaskException(Exception):
22
+ def __init__(self, task_id: str):
23
+ self._task_id = task_id
24
+
25
+ def __str__(self):
26
+ return f"No record found for task {self._task_id}"
27
+
28
+
29
+ class IStateManager(ABC, Generic[R]):
30
+ @abstractmethod
31
+ def read_state(self, task_id: str) -> AsyncTaskState[R]:
32
+ pass
33
+
34
+ @abstractmethod
35
+ def write_state(self, state: AsyncTaskState[R]) -> None:
36
+ pass
37
+
38
+ def update_status(self, task_id: str, new_status: str) -> None:
39
+ state = self.read_state(task_id)
40
+ state.task_status = new_status
41
+ self.write_state(state)
42
+
43
+ def save_result(self, task_id: str, task_result: R) -> None:
44
+ state = self.read_state(task_id)
45
+ state.task_status = TASK_STATUSES["COMPLETED"]
46
+ state.task_result = task_result
47
+ self.write_state(state)
48
+
49
+
50
+ class StateManager(IStateManager[R]):
51
+ """
52
+ Stores task state on local disk
53
+ """
54
+
55
+ def __init__(self, task_state_class: Type[AsyncTaskState[R]], state_dir) -> None:
56
+ self._task_state_class = task_state_class
57
+ self._state_dir = state_dir
58
+
59
+ def read_state(self, task_id: str) -> AsyncTaskState[R]:
60
+ task_state_path = os.path.join(self._state_dir, f"{task_id}.json")
61
+ if not os.path.isfile(task_state_path):
62
+ raise NoSuchTaskException(task_id)
63
+
64
+ with open(task_state_path, "r") as f:
65
+ return self._task_state_class(**json.loads(f.read()))
66
+
67
+ def write_state(self, state: AsyncTaskState[R]) -> None:
68
+ task_state_path = os.path.join(self._state_dir, f"{state.task_id}.json")
69
+ with open(task_state_path, "w") as f:
70
+ json.dump(state.model_dump(), f)
71
+
72
+
73
+ class TaskStateFetchException(Exception):
74
+ def __init__(self, message: str):
75
+ super().__init__(message)
76
+
77
+
78
+ class RemoteStateManagerFactory:
79
+ """
80
+ Stores task state in the interaction store
81
+ """
82
+
83
+ def __init__(
84
+ self, agent_name: str, actor_id: UUID, interactions_service: InteractionsService
85
+ ):
86
+ """
87
+ :param agent_name: Used to form the event type that will hold the task state in the interactions store
88
+ :param actor_id: Associated with the events written to the interactions store
89
+ :param interactions_service:
90
+ """
91
+ self.agent_name = agent_name
92
+ self.actor_id = actor_id
93
+ self.interactions_service = interactions_service
94
+
95
+ def for_message(self, message_id: str) -> IStateManager[R]:
96
+ return RemoteStateManager(
97
+ self.agent_name, self.actor_id, self.interactions_service, message_id
98
+ )
99
+
100
+
101
+ class RemoteStateManager(IStateManager[R]):
102
+ """
103
+ Stores task state in the interaction store
104
+ """
105
+
106
+ _TASK_STATE_EVENT_TYPE = "agent:{}:task_state"
107
+
108
+ def __init__(
109
+ self,
110
+ agent_name: str,
111
+ actor_id: UUID,
112
+ interactions_service: InteractionsService,
113
+ message_id: str,
114
+ ):
115
+ """
116
+ :param agent_name: Agent that saved the task
117
+ :param actor_id: ID for the agent (ignored when reading)
118
+ :param message_id: The message that initiated the request for task status
119
+ """
120
+ self.agent_name = agent_name
121
+ self.actor_id = actor_id
122
+ self.message_id = message_id
123
+ self.interactions_service = interactions_service
124
+
125
+ def read_state(self, task_id: str) -> AsyncTaskState[R]:
126
+ event_type = RemoteStateManager._TASK_STATE_EVENT_TYPE.format(self.agent_name)
127
+ response = (
128
+ self.interactions_service.fetch_thread_messages_and_events_for_message(
129
+ self.message_id, [event_type]
130
+ )
131
+ )
132
+ latest_state: Optional[AsyncTaskState[R]] = None
133
+ latest_timestamp = None
134
+ for msg in response.messages or []:
135
+ for event in msg.events or []:
136
+ try:
137
+ state = AsyncTaskState[Any].model_validate(event.data)
138
+ except Exception as e:
139
+ # Event json blob has unexpected format
140
+ raise TaskStateFetchException(
141
+ f"Event of type {event_type} for message {self.message_id} does not deserialize to AsyncTaskState: {e}"
142
+ )
143
+ if state.task_id != task_id:
144
+ continue
145
+ if latest_state is None or (
146
+ latest_timestamp and event.timestamp > latest_timestamp
147
+ ):
148
+ latest_state = state
149
+ latest_timestamp = event.timestamp
150
+
151
+ if not latest_state:
152
+ raise NoSuchTaskException(task_id)
153
+ return latest_state
154
+
155
+ def write_state(self, state: AsyncTaskState[R]) -> None:
156
+ event = Event(
157
+ type=RemoteStateManager._TASK_STATE_EVENT_TYPE.format(self.agent_name),
158
+ actor_id=self.actor_id,
159
+ timestamp=datetime.now(tz=timezone.utc),
160
+ message_id=self.message_id,
161
+ data=state.model_dump(),
162
+ )
163
+ self.interactions_service.save_event(event)
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: nora_lib
3
- Version: 0.0.5.dev3
3
+ Version: 0.0.6.dev1
4
4
  Summary: For making and coordinating agents and tools
5
5
  Home-page: https://github.com/allenai/nora_lib
6
6
  Requires-Python: >=3.9
7
7
  Requires-Dist: pydantic<3,>=2
8
8
  Requires-Dist: requests
9
+ Requires-Dist: boto3
9
10
  Provides-Extra: dev
10
11
  Requires-Dist: mypy; extra == "dev"
11
12
  Requires-Dist: pytest; extra == "dev"
@@ -1,5 +1,6 @@
1
1
  pydantic<3,>=2
2
2
  requests
3
+ boto3
3
4
 
4
5
  [dev]
5
6
  mypy
@@ -1,13 +1,13 @@
1
1
  import setuptools
2
2
 
3
- runtime_requirements = ["pydantic>=2,<3", "requests"]
3
+ runtime_requirements = ["pydantic>=2,<3", "requests", "boto3"]
4
4
 
5
5
  # For running tests, linting, etc
6
6
  dev_requirements = ["mypy", "pytest", "black", "types-requests"]
7
7
 
8
8
  setuptools.setup(
9
9
  name="nora_lib",
10
- version="0.0.5.dev3",
10
+ version="0.0.6.dev1",
11
11
  description="For making and coordinating agents and tools",
12
12
  url="https://github.com/allenai/nora_lib",
13
13
  packages=setuptools.find_packages(exclude=(["tests"])),
@@ -1,53 +0,0 @@
1
- """
2
- Handles IO for asynchronous task-related state.
3
- Currently just reads/writes from local disk,
4
- the best and most robust mechanism.
5
-
6
- A StateManager instance must be initialized with
7
- a concrete subclass of `AsyncTaskState`, as implemented
8
- by dependent projects.
9
- """
10
-
11
- import json
12
- import os
13
- from typing import Generic, Optional, Type
14
-
15
- from nora_lib.tasks.models import AsyncTaskState, R, TASK_STATUSES
16
-
17
-
18
- class NoSuchTaskException(Exception):
19
- def __init__(self, task_id: str):
20
- self._task_id = task_id
21
-
22
- def __str__(self):
23
- return f"No record found for task {self._task_id}"
24
-
25
-
26
- class StateManager(Generic[R]):
27
- def __init__(self, task_state_class: Type[AsyncTaskState[R]], state_dir) -> None:
28
- self._task_state_class = task_state_class
29
- self._state_dir = state_dir
30
-
31
- def read_state(self, task_id: str) -> AsyncTaskState[R]:
32
- task_state_path = os.path.join(self._state_dir, f"{task_id}.json")
33
- if not os.path.isfile(task_state_path):
34
- raise NoSuchTaskException(task_id)
35
-
36
- with open(task_state_path, "r") as f:
37
- return self._task_state_class(**json.loads(f.read()))
38
-
39
- def write_state(self, state: AsyncTaskState[R]) -> None:
40
- task_state_path = os.path.join(self._state_dir, f"{state.task_id}.json")
41
- with open(task_state_path, "w") as f:
42
- json.dump(state.model_dump(), f)
43
-
44
- def update_status(self, task_id: str, new_status: str) -> None:
45
- state = self.read_state(task_id)
46
- state.task_status = new_status
47
- self.write_state(state)
48
-
49
- def save_result(self, task_id: str, task_result: R) -> None:
50
- state = self.read_state(task_id)
51
- state.task_status = TASK_STATUSES["COMPLETED"]
52
- state.task_result = task_result
53
- self.write_state(state)
File without changes
File without changes