aury-agent 0.0.12__py3-none-any.whl → 0.0.14__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.
@@ -65,8 +65,8 @@ class ToolContext:
65
65
 
66
66
  await global_emit(block)
67
67
 
68
- async def emit_hitl(self, request_id: str, data: dict[str, Any]) -> None:
69
- """Emit a HITL request block.
68
+ async def emit_hitl(self, hitl_id: str, data: dict[str, Any]) -> None:
69
+ """Emit a HITL block.
70
70
 
71
71
  Convenience method for tools that need user interaction.
72
72
  The data format is flexible - can be anything the frontend understands:
@@ -76,15 +76,15 @@ class ToolContext:
76
76
  - Rich content (product cards, file selection, etc.)
77
77
 
78
78
  Args:
79
- request_id: Unique ID for this HITL request
79
+ hitl_id: Unique ID for this HITL request
80
80
  data: Arbitrary data dict for frontend to render.
81
81
  Common fields: type, question, choices, default, context
82
82
  """
83
83
  from .block import BlockEvent, BlockKind
84
84
 
85
85
  await self.emit(BlockEvent(
86
- kind=BlockKind.HITL_REQUEST,
87
- data={"request_id": request_id, **data},
86
+ kind=BlockKind.HITL,
87
+ data={"hitl_id": hitl_id, **data},
88
88
  ))
89
89
 
90
90
 
@@ -92,15 +92,29 @@ class ToolContext:
92
92
  class ToolResult:
93
93
  """Tool execution result for LLM.
94
94
 
95
- Supports dual output for context management:
96
- - output: Complete output (raw), for storage and recall
97
- - truncated_output: Shortened output for context window
95
+ This is the text result returned to LLM. For frontend rendering,
96
+ tools should use ctx.emit(BlockEvent(...)) to PATCH the TOOL_USE block
97
+ with structured data during execution.
98
98
 
99
- If truncated_output is not provided, it defaults to output.
99
+ Fields:
100
+ - output: Complete text output for LLM
101
+ - truncated_output: Shortened output for context window management
102
+
103
+ Example (image generation tool):
104
+ # During execution, PATCH block with structured data for frontend
105
+ await ctx.emit(BlockEvent(
106
+ block_id=ctx.block_id,
107
+ kind=BlockKind.TOOL_USE,
108
+ op=BlockOp.PATCH,
109
+ data={"images": [{"url": "..."}], "progress": 100},
110
+ ))
111
+
112
+ # Return text for LLM
113
+ return ToolResult.success("已生成4张图片")
100
114
  """
101
- output: str # Complete output (raw)
115
+ output: str # Text output for LLM
102
116
  is_error: bool = False
103
- truncated_output: str | None = None # Shortened output (defaults to output)
117
+ truncated_output: str | None = None # Shortened output for LLM context window
104
118
 
105
119
  def __post_init__(self):
106
120
  # Default truncated to output if not provided
@@ -117,8 +131,8 @@ class ToolResult:
117
131
  """Create a successful result.
118
132
 
119
133
  Args:
120
- output: Complete output (raw)
121
- truncated_output: Shortened output for context (defaults to output)
134
+ output: Text output for LLM
135
+ truncated_output: Shortened output for LLM context (defaults to output)
122
136
  """
123
137
  return cls(
124
138
  output=output,
@@ -136,7 +150,11 @@ class ToolInvocationState(Enum):
136
150
  """Tool invocation state machine."""
137
151
  PARTIAL_CALL = "partial-call" # Arguments streaming
138
152
  CALL = "call" # Arguments complete, ready to execute
139
- RESULT = "result" # Execution complete
153
+ RESULT = "result" # Execution complete (deprecated, use SUCCESS/FAILED/ABORTED)
154
+ # Execution result states
155
+ SUCCESS = "success" # Execution successful
156
+ FAILED = "failed" # Execution failed (including timeout)
157
+ ABORTED = "aborted" # User aborted
140
158
 
141
159
 
142
160
  @dataclass
@@ -144,6 +162,7 @@ class ToolInvocation:
144
162
  """Tool invocation tracking (state machine)."""
145
163
  tool_call_id: str
146
164
  tool_name: str
165
+ block_id: str = "" # Associated TOOL_USE block ID
147
166
  state: ToolInvocationState = ToolInvocationState.PARTIAL_CALL
148
167
  args: dict[str, Any] = field(default_factory=dict)
149
168
  args_raw: str = "" # Raw JSON string for streaming
@@ -167,6 +186,7 @@ class ToolInvocation:
167
186
  result: str,
168
187
  is_error: bool = False,
169
188
  truncated_result: str | None = None,
189
+ status: ToolInvocationState | None = None,
170
190
  ) -> None:
171
191
  """Mark execution complete.
172
192
 
@@ -174,8 +194,17 @@ class ToolInvocation:
174
194
  result: Complete result (raw)
175
195
  is_error: Whether this is an error result
176
196
  truncated_result: Shortened result for context window (defaults to result)
197
+ status: Explicit status (SUCCESS/FAILED/ABORTED). If not provided,
198
+ automatically inferred from is_error flag.
177
199
  """
178
- self.state = ToolInvocationState.RESULT
200
+ # Set state based on explicit status or infer from is_error
201
+ if status:
202
+ self.state = status
203
+ elif is_error:
204
+ self.state = ToolInvocationState.FAILED
205
+ else:
206
+ self.state = ToolInvocationState.SUCCESS
207
+
179
208
  self.result = result
180
209
  self.truncated_result = truncated_result if truncated_result is not None else result
181
210
  self.is_error = is_error
@@ -190,9 +219,61 @@ class ToolInvocation:
190
219
 
191
220
 
192
221
  class BaseTool:
193
- """Base class for tools with common functionality."""
222
+ """Base class for tools with common functionality.
223
+
224
+ Tools can operate in two modes:
225
+
226
+ 1. Standard mode (default):
227
+ - Implement execute() method
228
+ - Tool runs to completion or raises HITLSuspend
229
+ - If HITLSuspend raised, user response becomes tool result
230
+
231
+ 2. Continuation mode:
232
+ - Set supports_continuation = True
233
+ - Implement execute_resumable() method
234
+ - Tool can pause mid-execution with HITLSuspend(resume_mode="continuation")
235
+ - When user responds, tool resumes from checkpoint
236
+ - Useful for OAuth, payment, multi-step wizards
237
+
238
+ Emit support:
239
+ Tools can emit BlockEvents using self.emit(). The emitter is automatically
240
+ set when the tool is executed via the agent framework. If no emitter is
241
+ available (e.g., standalone testing), emit calls are silently skipped.
242
+
243
+ Example:
244
+ async def execute(self, params, ctx):
245
+ await self.emit(BlockEvent(...))
246
+ return ToolResult.success("done")
247
+
248
+ Example (continuation mode):
249
+ class OAuthTool(BaseTool):
250
+ _name = "oauth_connect"
251
+ supports_continuation = True
252
+
253
+ async def execute_resumable(
254
+ self,
255
+ params: dict[str, Any],
256
+ ctx: ToolContext,
257
+ checkpoint: "ToolCheckpoint | None" = None,
258
+ ) -> ToolResult:
259
+ if checkpoint:
260
+ # Resume from checkpoint
261
+ token = checkpoint.user_response["access_token"]
262
+ return await self._complete_oauth(token, params)
263
+
264
+ # First execution - generate auth URL
265
+ auth_url = self._build_auth_url(params)
266
+ raise HITLSuspend(
267
+ request_id=generate_id("hitl"),
268
+ request_type="external_auth",
269
+ resume_mode="continuation",
270
+ tool_state={"step": "awaiting_callback"},
271
+ metadata={"auth_url": auth_url, "callback_id": "..."},
272
+ )
273
+ """
194
274
 
195
275
  _name: str = "base_tool"
276
+ _display_name: str | None = None # Optional display name for UI
196
277
  _description: str = "Base tool"
197
278
  _parameters: dict[str, Any] = {
198
279
  "type": "object",
@@ -201,10 +282,21 @@ class BaseTool:
201
282
  }
202
283
  _config: ToolConfig | None = None
203
284
 
285
+ # Continuation mode support
286
+ supports_continuation: bool = False
287
+
288
+ # Runtime context (set during execution)
289
+ _ctx: ToolContext | None = None
290
+
204
291
  @property
205
292
  def name(self) -> str:
206
293
  return self._name
207
294
 
295
+ @property
296
+ def display_name(self) -> str:
297
+ """Get display name for UI. Falls back to name if not set."""
298
+ return self._display_name or self._name
299
+
208
300
  @property
209
301
  def description(self) -> str:
210
302
  return self._description
@@ -218,10 +310,92 @@ class BaseTool:
218
310
  """Get tool config. Returns default config if not set."""
219
311
  return self._config or ToolConfig()
220
312
 
313
+ @property
314
+ def ctx(self) -> ToolContext | None:
315
+ """Get current execution context."""
316
+ return self._ctx
317
+
318
+ def _set_ctx(self, ctx: ToolContext | None) -> None:
319
+ """Set execution context. Called by framework before execute()."""
320
+ self._ctx = ctx
321
+
322
+ async def emit(self, block: Any) -> None:
323
+ """Emit a BlockEvent.
324
+
325
+ Uses the current ToolContext's emit function if available.
326
+ Silently skips if no context is set (e.g., standalone testing).
327
+
328
+ Args:
329
+ block: BlockEvent to emit
330
+ """
331
+ if self._ctx is not None:
332
+ await self._ctx.emit(block)
333
+ # If no ctx, silently skip - allows standalone testing
334
+
335
+ async def emit_hitl(self, hitl_id: str, data: dict[str, Any]) -> None:
336
+ """Emit a HITL block.
337
+
338
+ Convenience method for tools that need user interaction.
339
+ Silently skips if no context is set.
340
+
341
+ Args:
342
+ hitl_id: Unique ID for this HITL request
343
+ data: Arbitrary data dict for frontend to render.
344
+ """
345
+ if self._ctx is not None:
346
+ await self._ctx.emit_hitl(hitl_id, data)
347
+
221
348
  async def execute(self, params: dict[str, Any], ctx: ToolContext) -> ToolResult:
222
- """Override this method."""
349
+ """Execute tool (standard mode).
350
+
351
+ Override this method for standard tools.
352
+ For continuation-capable tools, override execute_resumable() instead.
353
+ """
223
354
  raise NotImplementedError("Subclass must implement execute()")
224
355
 
356
+ async def execute_resumable(
357
+ self,
358
+ params: dict[str, Any],
359
+ ctx: ToolContext,
360
+ checkpoint: Any | None = None, # ToolCheckpoint, use Any to avoid circular import
361
+ ) -> ToolResult:
362
+ """Execute tool with continuation support.
363
+
364
+ Override this method for tools that need to pause mid-execution
365
+ and resume later (e.g., OAuth, payment, external callbacks).
366
+
367
+ Args:
368
+ params: Tool parameters from LLM
369
+ ctx: Tool execution context
370
+ checkpoint: If resuming, contains saved state and user response.
371
+ None on first execution.
372
+
373
+ Returns:
374
+ ToolResult on completion
375
+
376
+ Raises:
377
+ HITLSuspend: To pause and wait for user/callback.
378
+ Set resume_mode="continuation" and provide tool_state.
379
+
380
+ Example:
381
+ async def execute_resumable(self, params, ctx, checkpoint=None):
382
+ if checkpoint:
383
+ # Resuming - use checkpoint.user_response
384
+ return await self._continue(checkpoint)
385
+
386
+ # First run - do initial work, then suspend
387
+ partial_result = await self._step_one(params)
388
+ raise HITLSuspend(
389
+ request_id=generate_id("hitl"),
390
+ resume_mode="continuation",
391
+ tool_state={"partial": partial_result},
392
+ ...
393
+ )
394
+ """
395
+ # Default: delegate to standard execute()
396
+ # Tools that support continuation should override this
397
+ return await self.execute(params, ctx)
398
+
225
399
  def get_info(self) -> ToolInfo:
226
400
  """Get tool info."""
227
401
  return ToolInfo(
@@ -239,8 +413,10 @@ class ToolConfig:
239
413
  requires_permission: bool = False # Needs HITL approval
240
414
  permission_message: str | None = None
241
415
  stream_arguments: bool = False # Stream tool arguments to client
416
+ require_purpose: bool = False # Generate purpose via middleware (async LLM call)
417
+
242
418
 
243
419
  # Retry configuration
244
420
  max_retries: int = 0 # 0 = no retry
245
421
  retry_delay: float = 1.0 # Base delay between retries (seconds)
246
- retry_backoff: float = 2.0 # Exponential backoff multiplier
422
+ retry_backoff: float = 2.0 # Exponential backoff multiplier
@@ -14,6 +14,7 @@ from .exceptions import (
14
14
  HITLTimeoutError,
15
15
  HITLCancelledError,
16
16
  HITLRequest,
17
+ ToolCheckpoint,
17
18
  )
18
19
  from .ask_user import (
19
20
  AskUserTool,
@@ -44,6 +45,7 @@ __all__ = [
44
45
  "HITLCancelledError",
45
46
  # Types
46
47
  "HITLRequest",
48
+ "ToolCheckpoint",
47
49
  # Tools
48
50
  "AskUserTool",
49
51
  "ConfirmTool",
@@ -87,13 +87,13 @@ class AskUserTool(BaseTool):
87
87
 
88
88
  from ..core.logging import tool_logger as logger
89
89
 
90
- # Generate request ID
91
- request_id = generate_id("req")
90
+ # Generate HITL ID
91
+ hitl_id = generate_id("hitl")
92
92
  logger.info(
93
93
  "ask_user HITL request",
94
94
  extra={
95
95
  "invocation_id": ctx.invocation_id,
96
- "request_id": request_id,
96
+ "hitl_id": hitl_id,
97
97
  "question": question[:100],
98
98
  "has_options": options is not None,
99
99
  },
@@ -101,10 +101,9 @@ class AskUserTool(BaseTool):
101
101
 
102
102
  # Create HITL request data
103
103
  request = HITLRequest(
104
- request_id=request_id,
105
- request_type="ask_user",
106
- message=question,
107
- options=options,
104
+ hitl_id=hitl_id,
105
+ hitl_type="ask_user",
106
+ data={"message": question, "options": options},
108
107
  tool_name=self._name,
109
108
  metadata={"context": context} if context else {},
110
109
  )
@@ -121,18 +120,27 @@ class AskUserTool(BaseTool):
121
120
  if ctx.backends and ctx.backends.invocation:
122
121
  await ctx.backends.invocation.update(ctx.invocation_id, {
123
122
  "status": "suspended",
124
- "pending_request_id": request_id,
125
- "pending_request_type": "ask_user",
126
- "pending_request_data": request.to_dict(),
127
123
  })
128
124
 
129
- # Emit HITL request block to frontend
125
+ # Store HITL record
126
+ if ctx.backends and ctx.backends.hitl:
127
+ await ctx.backends.hitl.create(
128
+ hitl_id=hitl_id,
129
+ hitl_type="ask_user",
130
+ session_id=ctx.session_id,
131
+ invocation_id=ctx.invocation_id,
132
+ data={"message": question, "options": options},
133
+ metadata={"context": context} if context else None,
134
+ tool_name=self._name,
135
+ )
136
+
137
+ # Emit HITL block to frontend
130
138
  await ctx.emit(BlockEvent(
131
- kind="hitl_request",
139
+ kind="hitl",
132
140
  data={
133
- "request_id": request_id,
134
- "type": "ask_user",
135
- "question": question,
141
+ "hitl_id": hitl_id,
142
+ "hitl_type": "ask_user",
143
+ "message": question,
136
144
  "options": options,
137
145
  "context": context,
138
146
  },
@@ -143,14 +151,13 @@ class AskUserTool(BaseTool):
143
151
  "Suspending execution for HITL ask_user",
144
152
  extra={
145
153
  "invocation_id": ctx.invocation_id,
146
- "request_id": request_id,
154
+ "hitl_id": hitl_id,
147
155
  },
148
156
  )
149
157
  raise HITLSuspend(
150
- request_id=request_id,
151
- request_type="ask_user",
152
- message=question,
153
- options=options,
158
+ hitl_id=hitl_id,
159
+ hitl_type="ask_user",
160
+ data={"message": question, "options": options},
154
161
  tool_name=self._name,
155
162
  metadata={"context": context} if context else {},
156
163
  )
@@ -208,12 +215,12 @@ class ConfirmTool(BaseTool):
208
215
 
209
216
  from ..core.logging import tool_logger as logger
210
217
 
211
- request_id = generate_id("req")
218
+ hitl_id = generate_id("hitl")
212
219
  logger.info(
213
220
  "confirm HITL request",
214
221
  extra={
215
222
  "invocation_id": ctx.invocation_id,
216
- "request_id": request_id,
223
+ "hitl_id": hitl_id,
217
224
  "action": action[:100],
218
225
  "risk_level": risk_level,
219
226
  },
@@ -223,17 +230,19 @@ class ConfirmTool(BaseTool):
223
230
  if details:
224
231
  message += f"\n\nDetails: {details}"
225
232
 
233
+ hitl_data = {
234
+ "message": message,
235
+ "options": ["Yes, proceed", "No, cancel"],
236
+ "action": action,
237
+ "details": details,
238
+ "risk_level": risk_level,
239
+ }
240
+
226
241
  request = HITLRequest(
227
- request_id=request_id,
228
- request_type="confirm",
229
- message=message,
230
- options=["Yes, proceed", "No, cancel"],
242
+ hitl_id=hitl_id,
243
+ hitl_type="confirm",
244
+ data=hitl_data,
231
245
  tool_name=self._name,
232
- metadata={
233
- "action": action,
234
- "details": details,
235
- "risk_level": risk_level,
236
- },
237
246
  )
238
247
 
239
248
  # Checkpoint
@@ -248,21 +257,26 @@ class ConfirmTool(BaseTool):
248
257
  if ctx.backends and ctx.backends.invocation:
249
258
  await ctx.backends.invocation.update(ctx.invocation_id, {
250
259
  "status": "suspended",
251
- "pending_request_id": request_id,
252
- "pending_request_type": "confirm",
253
- "pending_request_data": request.to_dict(),
254
260
  })
255
261
 
262
+ # Store HITL record
263
+ if ctx.backends and ctx.backends.hitl:
264
+ await ctx.backends.hitl.create(
265
+ hitl_id=hitl_id,
266
+ hitl_type="confirm",
267
+ session_id=ctx.session_id,
268
+ invocation_id=ctx.invocation_id,
269
+ data=hitl_data,
270
+ tool_name=self._name,
271
+ )
272
+
256
273
  # Emit block
257
274
  await ctx.emit(BlockEvent(
258
- kind="hitl_request",
275
+ kind="hitl",
259
276
  data={
260
- "request_id": request_id,
261
- "type": "confirm",
262
- "action": action,
263
- "details": details,
264
- "risk_level": risk_level,
265
- "options": ["Yes, proceed", "No, cancel"],
277
+ "hitl_id": hitl_id,
278
+ "hitl_type": "confirm",
279
+ **hitl_data,
266
280
  },
267
281
  ))
268
282
 
@@ -270,16 +284,14 @@ class ConfirmTool(BaseTool):
270
284
  "Suspending execution for confirm",
271
285
  extra={
272
286
  "invocation_id": ctx.invocation_id,
273
- "request_id": request_id,
287
+ "hitl_id": hitl_id,
274
288
  },
275
289
  )
276
290
  raise HITLSuspend(
277
- request_id=request_id,
278
- request_type="confirm",
279
- message=message,
280
- options=["Yes, proceed", "No, cancel"],
291
+ hitl_id=hitl_id,
292
+ hitl_type="confirm",
293
+ data=hitl_data,
281
294
  tool_name=self._name,
282
- metadata=request.metadata,
283
295
  )
284
296
 
285
297