aury-agent 0.0.11__py3-none-any.whl → 0.0.13__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.
@@ -29,6 +29,7 @@ from .invocation import InvocationBackend, InMemoryInvocationBackend
29
29
  from .message import MessageBackend, InMemoryMessageBackend
30
30
  from .memory import MemoryBackend, InMemoryMemoryBackend
31
31
  from .artifact import ArtifactBackend, ArtifactSource, InMemoryArtifactBackend
32
+ from .hitl import HITLBackend, InMemoryHITLBackend
32
33
 
33
34
  # State backend - simplified to key-value
34
35
  from .state import StateBackend, StateStore, StoreBasedStateBackend, SQLiteStateBackend, MemoryStateBackend, FileStateBackend, CompositeStateBackend
@@ -87,6 +88,7 @@ class Backends:
87
88
  memory: MemoryBackend | None = None
88
89
  artifact: ArtifactBackend | None = None
89
90
  state: StateBackend | None = None
91
+ hitl: HITLBackend | None = None
90
92
 
91
93
  # Capability backends - optional
92
94
  snapshot: SnapshotBackend | None = None
@@ -108,6 +110,7 @@ class Backends:
108
110
  memory=InMemoryMemoryBackend(),
109
111
  artifact=InMemoryArtifactBackend(),
110
112
  state=MemoryStateBackend(),
113
+ hitl=InMemoryHITLBackend(),
111
114
  )
112
115
 
113
116
  @classmethod
@@ -127,6 +130,7 @@ class Backends:
127
130
  memory=InMemoryMemoryBackend(),
128
131
  artifact=InMemoryArtifactBackend(),
129
132
  state=SQLiteStateBackend(db_path),
133
+ hitl=InMemoryHITLBackend(),
130
134
  )
131
135
 
132
136
 
@@ -155,6 +159,10 @@ __all__ = [
155
159
  "ArtifactSource",
156
160
  "InMemoryArtifactBackend",
157
161
 
162
+ # HITL backend
163
+ "HITLBackend",
164
+ "InMemoryHITLBackend",
165
+
158
166
  # State backend (key-value)
159
167
  "StateBackend",
160
168
  "StateStore",
@@ -0,0 +1,8 @@
1
+ """HITL backend."""
2
+ from .types import HITLBackend
3
+ from .memory import InMemoryHITLBackend
4
+
5
+ __all__ = [
6
+ "HITLBackend",
7
+ "InMemoryHITLBackend",
8
+ ]
@@ -0,0 +1,100 @@
1
+ """In-memory HITL backend implementation."""
2
+ from __future__ import annotations
3
+
4
+ import time
5
+ from typing import Any
6
+
7
+
8
+ class InMemoryHITLBackend:
9
+ """In-memory HITL backend for development/testing."""
10
+
11
+ def __init__(self) -> None:
12
+ self._records: dict[str, dict[str, Any]] = {}
13
+
14
+ async def create(
15
+ self,
16
+ *,
17
+ hitl_id: str,
18
+ hitl_type: str,
19
+ session_id: str,
20
+ invocation_id: str,
21
+ data: dict[str, Any] | None = None,
22
+ metadata: dict[str, Any] | None = None,
23
+ block_id: str | None = None,
24
+ resume_mode: str = "response",
25
+ tool_state: dict[str, Any] | None = None,
26
+ checkpoint_id: str | None = None,
27
+ tool_name: str | None = None,
28
+ tool_call_id: str | None = None,
29
+ node_id: str | None = None,
30
+ expires_at: int | None = None,
31
+ ) -> None:
32
+ """Create a new HITL record."""
33
+ now = int(time.time())
34
+ self._records[hitl_id] = {
35
+ "hitl_id": hitl_id,
36
+ "hitl_type": hitl_type,
37
+ "session_id": session_id,
38
+ "invocation_id": invocation_id,
39
+ "data": data or {},
40
+ "metadata": metadata or {},
41
+ "block_id": block_id,
42
+ "status": "pending",
43
+ "resume_mode": resume_mode,
44
+ "tool_state": tool_state,
45
+ "checkpoint_id": checkpoint_id,
46
+ "tool_name": tool_name,
47
+ "tool_call_id": tool_call_id,
48
+ "node_id": node_id,
49
+ "expires_at": expires_at,
50
+ "user_response": None,
51
+ "responded_at": None,
52
+ "created_at": now,
53
+ "updated_at": now,
54
+ }
55
+
56
+ async def get(self, hitl_id: str) -> dict[str, Any] | None:
57
+ """Get HITL record by ID."""
58
+ return self._records.get(hitl_id)
59
+
60
+ async def respond(
61
+ self,
62
+ hitl_id: str,
63
+ user_response: dict[str, Any],
64
+ ) -> None:
65
+ """Record user response to HITL."""
66
+ if hitl_id in self._records:
67
+ now = int(time.time())
68
+ self._records[hitl_id]["status"] = "completed"
69
+ self._records[hitl_id]["user_response"] = user_response
70
+ self._records[hitl_id]["responded_at"] = now
71
+ self._records[hitl_id]["updated_at"] = now
72
+
73
+ async def cancel(self, hitl_id: str) -> None:
74
+ """Cancel a pending HITL request."""
75
+ if hitl_id in self._records:
76
+ self._records[hitl_id]["status"] = "cancelled"
77
+ self._records[hitl_id]["updated_at"] = int(time.time())
78
+
79
+ async def get_pending_by_session(
80
+ self,
81
+ session_id: str,
82
+ ) -> list[dict[str, Any]]:
83
+ """Get all pending HITL for a session."""
84
+ return [
85
+ r for r in self._records.values()
86
+ if r["session_id"] == session_id and r["status"] == "pending"
87
+ ]
88
+
89
+ async def get_pending_by_invocation(
90
+ self,
91
+ invocation_id: str,
92
+ ) -> list[dict[str, Any]]:
93
+ """Get all pending HITL for an invocation."""
94
+ return [
95
+ r for r in self._records.values()
96
+ if r["invocation_id"] == invocation_id and r["status"] == "pending"
97
+ ]
98
+
99
+
100
+ __all__ = ["InMemoryHITLBackend"]
@@ -0,0 +1,132 @@
1
+ """HITL backend types and protocols."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Protocol, runtime_checkable
5
+
6
+
7
+ @runtime_checkable
8
+ class HITLBackend(Protocol):
9
+ """Protocol for HITL (Human-in-the-Loop) persistence.
10
+
11
+ Stores HITL requests and user responses for:
12
+ - ask_user, confirm, permission requests
13
+ - External auth callbacks
14
+ - Workflow human tasks
15
+
16
+ Example usage:
17
+ # Create HITL request
18
+ await backend.create(
19
+ hitl_id="hitl_123",
20
+ hitl_type="ask_user",
21
+ session_id="sess_456",
22
+ invocation_id="inv_789",
23
+ data={"message": "What do you want?", "options": ["A", "B"]},
24
+ )
25
+
26
+ # Get HITL
27
+ hitl = await backend.get("hitl_123")
28
+
29
+ # Respond
30
+ await backend.respond("hitl_123", {"answer": "A"})
31
+ """
32
+
33
+ async def create(
34
+ self,
35
+ *,
36
+ hitl_id: str,
37
+ hitl_type: str,
38
+ session_id: str,
39
+ invocation_id: str,
40
+ data: dict[str, Any] | None = None,
41
+ metadata: dict[str, Any] | None = None,
42
+ block_id: str | None = None,
43
+ resume_mode: str = "response",
44
+ tool_state: dict[str, Any] | None = None,
45
+ checkpoint_id: str | None = None,
46
+ tool_name: str | None = None,
47
+ tool_call_id: str | None = None,
48
+ node_id: str | None = None,
49
+ expires_at: int | None = None,
50
+ ) -> None:
51
+ """Create a new HITL record.
52
+
53
+ Args:
54
+ hitl_id: Unique HITL identifier
55
+ hitl_type: Type of HITL (ask_user, confirm, permission, etc.)
56
+ session_id: Parent session ID
57
+ invocation_id: Parent invocation ID
58
+ data: Type-specific data (message, options, etc.)
59
+ metadata: Additional metadata
60
+ block_id: Associated UI block ID
61
+ resume_mode: How to resume ("response" or "continuation")
62
+ tool_state: Tool internal state for continuation
63
+ checkpoint_id: Checkpoint ID for continuation
64
+ tool_name: Tool that triggered HITL
65
+ tool_call_id: Tool call ID
66
+ node_id: Workflow node ID
67
+ expires_at: Expiration timestamp (unix)
68
+ """
69
+ ...
70
+
71
+ async def get(self, hitl_id: str) -> dict[str, Any] | None:
72
+ """Get HITL record by ID.
73
+
74
+ Args:
75
+ hitl_id: HITL identifier
76
+
77
+ Returns:
78
+ HITL data dict or None if not found
79
+ """
80
+ ...
81
+
82
+ async def respond(
83
+ self,
84
+ hitl_id: str,
85
+ user_response: dict[str, Any],
86
+ ) -> None:
87
+ """Record user response to HITL.
88
+
89
+ Args:
90
+ hitl_id: HITL identifier
91
+ user_response: User's response data
92
+ """
93
+ ...
94
+
95
+ async def cancel(self, hitl_id: str) -> None:
96
+ """Cancel a pending HITL request.
97
+
98
+ Args:
99
+ hitl_id: HITL identifier
100
+ """
101
+ ...
102
+
103
+ async def get_pending_by_session(
104
+ self,
105
+ session_id: str,
106
+ ) -> list[dict[str, Any]]:
107
+ """Get all pending HITL for a session.
108
+
109
+ Args:
110
+ session_id: Session ID
111
+
112
+ Returns:
113
+ List of pending HITL records
114
+ """
115
+ ...
116
+
117
+ async def get_pending_by_invocation(
118
+ self,
119
+ invocation_id: str,
120
+ ) -> list[dict[str, Any]]:
121
+ """Get all pending HITL for an invocation.
122
+
123
+ Args:
124
+ invocation_id: Invocation ID
125
+
126
+ Returns:
127
+ List of pending HITL records
128
+ """
129
+ ...
130
+
131
+
132
+ __all__ = ["HITLBackend"]
aury/agents/core/base.py CHANGED
@@ -76,6 +76,11 @@ class AgentConfig:
76
76
  # Tool injection mode
77
77
  tool_mode: ToolInjectionMode = ToolInjectionMode.FUNCTION_CALL
78
78
 
79
+ # HITL state persistence (for resume support)
80
+ # If True, save agent_state when suspended for later resume
81
+ # If False, skip state persistence (lighter weight, but can't resume)
82
+ persist_hitl_state: bool = False
83
+
79
84
  # Extra metadata for agent-specific config
80
85
  metadata: dict[str, Any] = field(default_factory=dict)
81
86
 
@@ -352,6 +357,13 @@ class BaseAgent(ABC):
352
357
  logger.error(f"{self.agent_type}Agent run error, error={type(e).__name__}, invocation_id={self._ctx.invocation_id}", exc_info=True)
353
358
  raise
354
359
  finally:
360
+ # Cancel exec_task if still running (e.g., when run() is cancelled externally)
361
+ if exec_task and not exec_task.done():
362
+ exec_task.cancel()
363
+ try:
364
+ await exec_task
365
+ except asyncio.CancelledError:
366
+ pass
355
367
  _reset_current_ctx(ctx_token)
356
368
  _emit_queue_var.reset(queue_token)
357
369
  self._run_config = {} # Clear runtime overrides
@@ -276,6 +276,7 @@ class InvocationContext:
276
276
 
277
277
  # Tool execution context (set when executing a tool)
278
278
  tool_call_id: str | None = None
279
+ tool_block_id: str | None = None
279
280
 
280
281
  # Abort signals
281
282
  abort_self: asyncio.Event = field(default_factory=asyncio.Event)
@@ -40,54 +40,74 @@ class HITLSuspend(SuspendSignal):
40
40
  1. Display the request to user
41
41
  2. Resume execution after user responds
42
42
 
43
+ Supports two resume modes:
44
+ - "response": (Default) User response is returned to LLM as tool output.
45
+ - "continuation": Tool execution is resumed from checkpoint,
46
+ user response is passed to tool to continue processing.
47
+
43
48
  Attributes:
44
- request_id: Unique ID for matching response
45
- request_type: Type of request (ask_user, confirm, form, etc.)
46
- message: Display message for user
47
- options: Optional list of choices
49
+ hitl_id: Unique ID for matching response
50
+ hitl_type: Type of HITL (ask_user, confirm, external_auth, etc.)
51
+ data: Type-specific data (message, options, etc.)
48
52
  node_id: Workflow node ID if triggered from workflow
49
53
  tool_name: Tool name if triggered from tool
50
54
  block_id: Associated UI block ID
51
55
  metadata: Additional context
56
+ resume_mode: How to resume after user responds
57
+ tool_state: Tool internal state for continuation mode
58
+ checkpoint_id: Unique ID for tool checkpoint
52
59
  """
53
- request_id: str
54
- request_type: str = "ask_user"
55
- message: str | None = None
56
- options: list[str] | None = None
60
+ hitl_id: str
61
+ hitl_type: str = "ask_user"
62
+ data: dict[str, Any] = field(default_factory=dict) # Type-specific: {message, options, ...}
57
63
  node_id: str | None = None
58
64
  tool_name: str | None = None
59
65
  block_id: str | None = None
60
66
  metadata: dict[str, Any] = field(default_factory=dict)
61
67
 
68
+ # Continuation support
69
+ resume_mode: str = "response" # "response" | "continuation"
70
+ tool_state: dict[str, Any] | None = None # Internal state for continuation
71
+ checkpoint_id: str | None = None # Checkpoint ID for restoration
72
+
62
73
  def __post_init__(self):
63
74
  # Initialize BaseException with a message
64
- super().__init__(f"HITL suspend: {self.request_type} ({self.request_id})")
75
+ super().__init__(f"HITL suspend: {self.hitl_type} ({self.hitl_id})")
76
+
77
+ @property
78
+ def is_continuation(self) -> bool:
79
+ """Check if this suspend requires continuation mode."""
80
+ return self.resume_mode == "continuation"
65
81
 
66
82
  def to_dict(self) -> dict[str, Any]:
67
83
  """Convert to dictionary for storage/transmission."""
68
84
  return {
69
- "request_id": self.request_id,
70
- "request_type": self.request_type,
71
- "message": self.message,
72
- "options": self.options,
85
+ "hitl_id": self.hitl_id,
86
+ "hitl_type": self.hitl_type,
87
+ "data": self.data,
73
88
  "node_id": self.node_id,
74
89
  "tool_name": self.tool_name,
75
90
  "block_id": self.block_id,
76
91
  "metadata": self.metadata,
92
+ "resume_mode": self.resume_mode,
93
+ "tool_state": self.tool_state,
94
+ "checkpoint_id": self.checkpoint_id,
77
95
  }
78
96
 
79
97
  @classmethod
80
98
  def from_dict(cls, data: dict[str, Any]) -> "HITLSuspend":
81
99
  """Create from dictionary."""
82
100
  return cls(
83
- request_id=data["request_id"],
84
- request_type=data.get("request_type", "ask_user"),
85
- message=data.get("message"),
86
- options=data.get("options"),
101
+ hitl_id=data["hitl_id"],
102
+ hitl_type=data.get("hitl_type", "ask_user"),
103
+ data=data.get("data", {}),
87
104
  node_id=data.get("node_id"),
88
105
  tool_name=data.get("tool_name"),
89
106
  block_id=data.get("block_id"),
90
107
  metadata=data.get("metadata", {}),
108
+ resume_mode=data.get("resume_mode", "response"),
109
+ tool_state=data.get("tool_state"),
110
+ checkpoint_id=data.get("checkpoint_id"),
91
111
  )
92
112
 
93
113
 
@@ -32,7 +32,6 @@ from .block import (
32
32
  thinking_delta,
33
33
  tool_use_block,
34
34
  tool_use_patch,
35
- tool_result_block,
36
35
  error_block,
37
36
  )
38
37
  from .message import (
@@ -86,7 +85,6 @@ __all__ = [
86
85
  "thinking_delta",
87
86
  "tool_use_block",
88
87
  "tool_use_patch",
89
- "tool_result_block",
90
88
  "error_block",
91
89
  # Message
92
90
  "MessageRole",
@@ -27,8 +27,11 @@ class BlockKind(str, Enum):
27
27
  THINKING = "thinking" # LLM reasoning (collapsible)
28
28
 
29
29
  # === Tool Execution ===
30
- TOOL_USE = "tool_use" # Tool call
31
- TOOL_RESULT = "tool_result" # Tool result
30
+ # TOOL_USE manages entire tool lifecycle via PATCH:
31
+ # APPLY → {name, call_id, arguments, status: "pending"}
32
+ # PATCH → {status: "running", progress: ...} (tool can emit during execution)
33
+ # PATCH → {status: "success"} (framework emits on completion)
34
+ TOOL_USE = "tool_use"
32
35
 
33
36
  # === Agent ===
34
37
  SUB_AGENT = "sub_agent" # Sub-agent delegation
@@ -40,7 +43,7 @@ class BlockKind(str, Enum):
40
43
  NODE = "node" # Workflow node execution block
41
44
 
42
45
  # === HITL ===
43
- HITL_REQUEST = "hitl_request" # HITL request (any format - choice, input, confirm, etc.)
46
+ HITL = "hitl" # Human-in-the-loop (ask_user, confirm, permission, etc.)
44
47
 
45
48
  # === Control Flow ===
46
49
  YIELD = "yield" # Return control to parent
@@ -700,26 +703,6 @@ def tool_use_patch(block_id: str, args: dict[str, Any]) -> BlockEvent:
700
703
  )
701
704
 
702
705
 
703
- def tool_result_block(
704
- block_id: str,
705
- tool_use_id: str,
706
- content: str,
707
- is_error: bool = False,
708
- parent_id: str | None = None,
709
- ) -> BlockEvent:
710
- """Create a tool result block."""
711
- return BlockEvent(
712
- block_id=block_id,
713
- parent_id=parent_id,
714
- kind=BlockKind.TOOL_RESULT,
715
- op=BlockOp.APPLY,
716
- data={
717
- "tool_use_id": tool_use_id,
718
- "content": content,
719
- "is_error": is_error,
720
- },
721
- )
722
-
723
706
 
724
707
  def error_block(
725
708
  block_id: str,