aury-agent 0.0.4__py3-none-any.whl → 0.0.5__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,241 @@
1
+ """Pause and resume helpers for ReactAgent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import TYPE_CHECKING, AsyncIterator
7
+
8
+ from ..core.event_bus import Events
9
+ from ..core.types.block import BlockEvent, BlockKind, BlockOp
10
+ from ..core.types import (
11
+ Invocation,
12
+ InvocationState,
13
+ ToolInvocationState,
14
+ )
15
+ from ..llm import LLMMessage
16
+
17
+ if TYPE_CHECKING:
18
+ from .agent import ReactAgent
19
+
20
+
21
+ async def pause_agent(agent: "ReactAgent") -> str:
22
+ """Pause execution and return invocation ID for later resume.
23
+
24
+ Saves current state to the invocation for later resumption.
25
+
26
+ Args:
27
+ agent: ReactAgent instance
28
+
29
+ Returns:
30
+ Invocation ID for resuming
31
+ """
32
+ if not agent._current_invocation:
33
+ raise RuntimeError("No active invocation to pause")
34
+
35
+ # Mark as paused
36
+ agent._paused = True
37
+ agent._current_invocation.mark_paused()
38
+
39
+ # Save state for resumption
40
+ agent._current_invocation.agent_state = {
41
+ "step": agent._current_step,
42
+ "message_history": [
43
+ {"role": m.role, "content": m.content} for m in agent._message_history
44
+ ],
45
+ "text_buffer": agent._text_buffer,
46
+ }
47
+ agent._current_invocation.step_count = agent._current_step
48
+
49
+ # Save pending tool calls
50
+ agent._current_invocation.pending_tool_ids = [
51
+ inv.tool_call_id
52
+ for inv in agent._tool_invocations
53
+ if inv.state == ToolInvocationState.CALL
54
+ ]
55
+
56
+ # Persist invocation
57
+ if agent.ctx.backends and agent.ctx.backends.invocation:
58
+ await agent.ctx.backends.invocation.update(
59
+ agent._current_invocation.id,
60
+ agent._current_invocation.to_dict(),
61
+ )
62
+
63
+ await agent.bus.publish(
64
+ Events.INVOCATION_PAUSE,
65
+ {
66
+ "invocation_id": agent._current_invocation.id,
67
+ "step": agent._current_step,
68
+ },
69
+ )
70
+
71
+ return agent._current_invocation.id
72
+
73
+
74
+ async def resume_agent_internal(agent: "ReactAgent", invocation_id: str) -> None:
75
+ """Internal resume logic using emit.
76
+
77
+ Args:
78
+ agent: ReactAgent instance
79
+ invocation_id: Invocation ID to resume
80
+ """
81
+ from .step import execute_step
82
+ from .tools import process_tool_results
83
+ from .persistence import save_assistant_message, save_tool_messages
84
+
85
+ # Load invocation
86
+ if not agent.ctx.backends or not agent.ctx.backends.invocation:
87
+ raise ValueError("No invocation backend available")
88
+ inv_data = await agent.ctx.backends.invocation.get(invocation_id)
89
+ if not inv_data:
90
+ raise ValueError(f"Invocation not found: {invocation_id}")
91
+
92
+ invocation = Invocation.from_dict(inv_data)
93
+
94
+ if invocation.state != InvocationState.PAUSED:
95
+ raise ValueError(f"Invocation is not paused: {invocation.state}")
96
+
97
+ # Restore state
98
+ agent._current_invocation = invocation
99
+ agent._paused = False
100
+ agent._running = True
101
+
102
+ agent_state = invocation.agent_state or {}
103
+ agent._current_step = agent_state.get("step", 0)
104
+ agent._text_buffer = agent_state.get("text_buffer", "")
105
+
106
+ # Restore message history
107
+ agent._message_history = [
108
+ LLMMessage(role=m["role"], content=m["content"])
109
+ for m in agent_state.get("message_history", [])
110
+ ]
111
+
112
+ # Mark as running
113
+ invocation.state = InvocationState.RUNNING
114
+
115
+ await agent.bus.publish(
116
+ Events.INVOCATION_RESUME,
117
+ {
118
+ "invocation_id": invocation_id,
119
+ "step": agent._current_step,
120
+ },
121
+ )
122
+
123
+ # Continue execution loop
124
+ try:
125
+ finish_reason = None
126
+
127
+ while not await agent._check_abort() and not agent._paused:
128
+ agent._current_step += 1
129
+
130
+ if agent._current_step > agent.config.max_steps:
131
+ await agent.ctx.emit(BlockEvent(
132
+ kind=BlockKind.ERROR,
133
+ op=BlockOp.APPLY,
134
+ data={"message": f"Max steps ({agent.config.max_steps}) exceeded"},
135
+ ))
136
+ break
137
+
138
+ finish_reason = await execute_step(agent)
139
+
140
+ # Save assistant message (real-time persistence)
141
+ await save_assistant_message(agent)
142
+
143
+ if finish_reason == "end_turn" and not agent._tool_invocations:
144
+ break
145
+
146
+ if agent._tool_invocations:
147
+ await process_tool_results(agent)
148
+
149
+ # Save tool messages (real-time persistence)
150
+ await save_tool_messages(agent)
151
+
152
+ agent._tool_invocations.clear()
153
+
154
+ if not agent._paused:
155
+ agent._current_invocation.state = InvocationState.COMPLETED
156
+ agent._current_invocation.finished_at = __import__("datetime").datetime.now()
157
+
158
+ except Exception as e:
159
+ agent._current_invocation.state = InvocationState.FAILED
160
+ await agent.ctx.emit(BlockEvent(
161
+ kind=BlockKind.ERROR,
162
+ op=BlockOp.APPLY,
163
+ data={"message": str(e)},
164
+ ))
165
+ raise
166
+
167
+ finally:
168
+ agent._running = False
169
+
170
+
171
+ async def resume_agent(agent: "ReactAgent", invocation_id: str) -> AsyncIterator[BlockEvent]:
172
+ """Resume paused execution.
173
+
174
+ Args:
175
+ agent: ReactAgent instance
176
+ invocation_id: ID from pause()
177
+
178
+ Yields:
179
+ BlockEvent streaming events
180
+ """
181
+ from ..core.context import _emit_queue_var
182
+
183
+ queue: asyncio.Queue[BlockEvent] = asyncio.Queue()
184
+ token = _emit_queue_var.set(queue)
185
+
186
+ try:
187
+ exec_task = asyncio.create_task(resume_agent_internal(agent, invocation_id))
188
+ get_task: asyncio.Task | None = None
189
+
190
+ # Event-driven processing - no timeout delays
191
+ while True:
192
+ # First drain any pending items from queue (non-blocking)
193
+ while True:
194
+ try:
195
+ block = queue.get_nowait()
196
+ yield block
197
+ except asyncio.QueueEmpty:
198
+ break
199
+
200
+ # Exit if task is done and queue is empty
201
+ if exec_task.done() and queue.empty():
202
+ break
203
+
204
+ # Create get_task if needed
205
+ if get_task is None or get_task.done():
206
+ get_task = asyncio.create_task(queue.get())
207
+
208
+ # Wait for EITHER: queue item OR exec_task completion
209
+ done, _ = await asyncio.wait(
210
+ {get_task, exec_task},
211
+ return_when=asyncio.FIRST_COMPLETED,
212
+ )
213
+
214
+ if get_task in done:
215
+ try:
216
+ block = get_task.result()
217
+ yield block
218
+ get_task = None
219
+ except asyncio.CancelledError:
220
+ pass
221
+
222
+ # Cancel pending get_task if any
223
+ if get_task and not get_task.done():
224
+ get_task.cancel()
225
+ try:
226
+ await get_task
227
+ except asyncio.CancelledError:
228
+ pass
229
+
230
+ # Final drain after task completion
231
+ while not queue.empty():
232
+ try:
233
+ block = queue.get_nowait()
234
+ yield block
235
+ except asyncio.QueueEmpty:
236
+ break
237
+
238
+ await exec_task
239
+
240
+ finally:
241
+ _emit_queue_var.reset(token)
@@ -0,0 +1,182 @@
1
+ """State persistence helpers for ReactAgent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ if TYPE_CHECKING:
8
+ from .agent import ReactAgent
9
+ from ..core.types import PromptInput
10
+
11
+
12
+ def save_messages_to_state(agent: "ReactAgent") -> None:
13
+ """Save execution state for recovery.
14
+
15
+ This saves to state.execution namespace:
16
+ - step: current step number
17
+ - message_ids: references to raw messages (if using RawMessageMiddleware)
18
+ - For legacy/fallback: message_history as serialized data
19
+
20
+ Note: With RawMessageMiddleware, message_ids are automatically populated
21
+ by the middleware. This method saves additional execution state.
22
+
23
+ Args:
24
+ agent: ReactAgent instance
25
+ """
26
+ if not agent._state:
27
+ return
28
+
29
+ # Save step to execution namespace
30
+ agent._state.execution["step"] = agent._current_step
31
+
32
+ # Save invocation_id for recovery context
33
+ if agent._current_invocation:
34
+ agent._state.execution["invocation_id"] = agent._current_invocation.id
35
+
36
+ # Fallback: if message_ids not populated by middleware, save full history
37
+ # This ensures backward compatibility when RawMessageMiddleware is not used
38
+ if "message_ids" not in agent._state.execution:
39
+ messages_data = []
40
+ for msg in agent._message_history:
41
+ msg_dict = {"role": msg.role, "content": msg.content}
42
+ if hasattr(msg, "tool_call_id") and msg.tool_call_id:
43
+ msg_dict["tool_call_id"] = msg.tool_call_id
44
+ messages_data.append(msg_dict)
45
+ agent._state.execution["message_history"] = messages_data
46
+
47
+
48
+ def clear_messages_from_state(agent: "ReactAgent") -> None:
49
+ """Clear execution state after invocation completes.
50
+
51
+ Called when invocation completes normally. Historical messages
52
+ are already persisted (truncated) via MessageStore.
53
+
54
+ Args:
55
+ agent: ReactAgent instance
56
+ """
57
+ if not agent._state:
58
+ return
59
+
60
+ # Clear execution namespace
61
+ agent._state.execution.clear()
62
+
63
+
64
+ async def trigger_message_save(agent: "ReactAgent", message: dict) -> dict | None:
65
+ """Trigger on_message_save hook via middleware.
66
+
67
+ Message persistence is handled by MessageBackendMiddleware.
68
+ Agent only triggers the hook, doesn't save directly.
69
+
70
+ Args:
71
+ agent: ReactAgent instance
72
+ message: Message dict with role, content, etc.
73
+
74
+ Returns:
75
+ Modified message or None if blocked
76
+ """
77
+ # Check if message saving is disabled (e.g., for sub-agents with record_messages=False)
78
+ if getattr(agent, '_disable_message_save', False):
79
+ return message
80
+
81
+ if not agent.middleware:
82
+ return message
83
+
84
+ namespace = getattr(agent, '_message_namespace', None)
85
+ mw_context = {
86
+ "session_id": agent.session.id,
87
+ "agent_id": agent.name,
88
+ "namespace": namespace,
89
+ }
90
+
91
+ return await agent.middleware.process_message_save(message, mw_context)
92
+
93
+
94
+ async def save_user_message(agent: "ReactAgent", input: "PromptInput") -> None:
95
+ """Trigger save for user message.
96
+
97
+ Args:
98
+ agent: ReactAgent instance
99
+ input: User prompt input
100
+ """
101
+ # Build user content
102
+ content: str | list[dict] = input.text
103
+ if agent._agent_context and agent._agent_context.user_content:
104
+ content = agent._agent_context.user_content + "\n\n" + input.text
105
+
106
+ if input.attachments:
107
+ content_parts: list[dict] = [{"type": "text", "text": content}]
108
+ for attachment in input.attachments:
109
+ content_parts.append(attachment)
110
+ content = content_parts
111
+
112
+ # Build message and trigger hook
113
+ message = {
114
+ "role": "user",
115
+ "content": content,
116
+ "invocation_id": agent._current_invocation.id if agent._current_invocation else "",
117
+ }
118
+
119
+ await trigger_message_save(agent, message)
120
+
121
+
122
+ async def save_assistant_message(agent: "ReactAgent") -> None:
123
+ """Trigger save for assistant message.
124
+
125
+ Args:
126
+ agent: ReactAgent instance
127
+ """
128
+ if not agent._text_buffer and not agent._tool_invocations and not agent._thinking_buffer:
129
+ return
130
+
131
+ # Build assistant content
132
+ content: str | list[dict] = agent._text_buffer
133
+ if agent._tool_invocations or agent._thinking_buffer:
134
+ content_parts: list[dict] = []
135
+ # Claude requires thinking block first when thinking is enabled
136
+ if agent._thinking_buffer:
137
+ content_parts.append({"type": "thinking", "thinking": agent._thinking_buffer})
138
+ if agent._text_buffer:
139
+ content_parts.append({"type": "text", "text": agent._text_buffer})
140
+ for inv in agent._tool_invocations:
141
+ content_parts.append({
142
+ "type": "tool_use",
143
+ "id": inv.tool_call_id,
144
+ "name": inv.tool_name,
145
+ "input": inv.args,
146
+ })
147
+ content = content_parts
148
+
149
+ # Build message and trigger hook
150
+ message = {
151
+ "role": "assistant",
152
+ "content": content,
153
+ "invocation_id": agent._current_invocation.id if agent._current_invocation else "",
154
+ }
155
+
156
+ await trigger_message_save(agent, message)
157
+
158
+
159
+ async def save_tool_messages(agent: "ReactAgent") -> None:
160
+ """Trigger save for tool result messages.
161
+
162
+ Args:
163
+ agent: ReactAgent instance
164
+ """
165
+ for inv in agent._tool_invocations:
166
+ if inv.result is not None:
167
+ # Build tool result message
168
+ content: list[dict] = [{
169
+ "type": "tool_result",
170
+ "tool_use_id": inv.tool_call_id,
171
+ "content": inv.result,
172
+ "is_error": inv.is_error,
173
+ }]
174
+
175
+ message = {
176
+ "role": "tool",
177
+ "content": content,
178
+ "tool_call_id": inv.tool_call_id,
179
+ "invocation_id": agent._current_invocation.id if agent._current_invocation else "",
180
+ }
181
+
182
+ await trigger_message_save(agent, message)