agent-scout-core 0.1.0__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 (79) hide show
  1. agent_scout_core-0.1.0/.gitignore +81 -0
  2. agent_scout_core-0.1.0/PKG-INFO +41 -0
  3. agent_scout_core-0.1.0/README.md +5 -0
  4. agent_scout_core-0.1.0/pyproject.toml +69 -0
  5. agent_scout_core-0.1.0/src/agent_scout/__init__.py +3 -0
  6. agent_scout_core-0.1.0/src/agent_scout/agent.py +183 -0
  7. agent_scout_core-0.1.0/src/agent_scout/approval.py +246 -0
  8. agent_scout_core-0.1.0/src/agent_scout/comparison.py +256 -0
  9. agent_scout_core-0.1.0/src/agent_scout/config/__init__.py +0 -0
  10. agent_scout_core-0.1.0/src/agent_scout/config/settings.py +192 -0
  11. agent_scout_core-0.1.0/src/agent_scout/db/__init__.py +0 -0
  12. agent_scout_core-0.1.0/src/agent_scout/db/database.py +398 -0
  13. agent_scout_core-0.1.0/src/agent_scout/db/migrations/001_initial.sql +96 -0
  14. agent_scout_core-0.1.0/src/agent_scout/db/models.py +95 -0
  15. agent_scout_core-0.1.0/src/agent_scout/events.py +150 -0
  16. agent_scout_core-0.1.0/src/agent_scout/export.py +210 -0
  17. agent_scout_core-0.1.0/src/agent_scout/llm/__init__.py +0 -0
  18. agent_scout_core-0.1.0/src/agent_scout/llm/budget.py +211 -0
  19. agent_scout_core-0.1.0/src/agent_scout/llm/cache.py +149 -0
  20. agent_scout_core-0.1.0/src/agent_scout/llm/estimator.py +186 -0
  21. agent_scout_core-0.1.0/src/agent_scout/llm/provider.py +343 -0
  22. agent_scout_core-0.1.0/src/agent_scout/llm/router.py +260 -0
  23. agent_scout_core-0.1.0/src/agent_scout/logging/__init__.py +0 -0
  24. agent_scout_core-0.1.0/src/agent_scout/logging/tracer.py +58 -0
  25. agent_scout_core-0.1.0/src/agent_scout/memory/__init__.py +0 -0
  26. agent_scout_core-0.1.0/src/agent_scout/memory/store.py +87 -0
  27. agent_scout_core-0.1.0/src/agent_scout/personality/__init__.py +0 -0
  28. agent_scout_core-0.1.0/src/agent_scout/personality/personality.py +128 -0
  29. agent_scout_core-0.1.0/src/agent_scout/plugins/__init__.py +0 -0
  30. agent_scout_core-0.1.0/src/agent_scout/plugins/base.py +169 -0
  31. agent_scout_core-0.1.0/src/agent_scout/plugins/browser.py +271 -0
  32. agent_scout_core-0.1.0/src/agent_scout/plugins/exporter.py +251 -0
  33. agent_scout_core-0.1.0/src/agent_scout/plugins/mcp_bridge.py +179 -0
  34. agent_scout_core-0.1.0/src/agent_scout/plugins/notifier.py +313 -0
  35. agent_scout_core-0.1.0/src/agent_scout/plugins/price_tracker.py +240 -0
  36. agent_scout_core-0.1.0/src/agent_scout/plugins/registry.py +169 -0
  37. agent_scout_core-0.1.0/src/agent_scout/plugins/web_search.py +335 -0
  38. agent_scout_core-0.1.0/src/agent_scout/price_monitor.py +291 -0
  39. agent_scout_core-0.1.0/src/agent_scout/react_loop.py +269 -0
  40. agent_scout_core-0.1.0/src/agent_scout/recovery.py +281 -0
  41. agent_scout_core-0.1.0/src/agent_scout/resilience.py +126 -0
  42. agent_scout_core-0.1.0/src/agent_scout/scheduler/__init__.py +0 -0
  43. agent_scout_core-0.1.0/src/agent_scout/scheduler/scheduler.py +241 -0
  44. agent_scout_core-0.1.0/src/agent_scout/security.py +72 -0
  45. agent_scout_core-0.1.0/src/agent_scout/task_manager.py +177 -0
  46. agent_scout_core-0.1.0/tests/__init__.py +0 -0
  47. agent_scout_core-0.1.0/tests/conftest.py +54 -0
  48. agent_scout_core-0.1.0/tests/test_agent.py +261 -0
  49. agent_scout_core-0.1.0/tests/test_approval.py +188 -0
  50. agent_scout_core-0.1.0/tests/test_cache.py +121 -0
  51. agent_scout_core-0.1.0/tests/test_comparison.py +168 -0
  52. agent_scout_core-0.1.0/tests/test_database.py +265 -0
  53. agent_scout_core-0.1.0/tests/test_events.py +155 -0
  54. agent_scout_core-0.1.0/tests/test_export.py +116 -0
  55. agent_scout_core-0.1.0/tests/test_integration.py +333 -0
  56. agent_scout_core-0.1.0/tests/test_llm_budget.py +218 -0
  57. agent_scout_core-0.1.0/tests/test_llm_estimator.py +152 -0
  58. agent_scout_core-0.1.0/tests/test_llm_provider.py +253 -0
  59. agent_scout_core-0.1.0/tests/test_llm_router.py +139 -0
  60. agent_scout_core-0.1.0/tests/test_memory.py +126 -0
  61. agent_scout_core-0.1.0/tests/test_personality.py +106 -0
  62. agent_scout_core-0.1.0/tests/test_plugin_base.py +204 -0
  63. agent_scout_core-0.1.0/tests/test_plugin_browser.py +200 -0
  64. agent_scout_core-0.1.0/tests/test_plugin_exporter.py +146 -0
  65. agent_scout_core-0.1.0/tests/test_plugin_mcp_bridge.py +177 -0
  66. agent_scout_core-0.1.0/tests/test_plugin_notifier.py +201 -0
  67. agent_scout_core-0.1.0/tests/test_plugin_price_tracker.py +150 -0
  68. agent_scout_core-0.1.0/tests/test_plugin_registry.py +269 -0
  69. agent_scout_core-0.1.0/tests/test_plugin_web_search.py +191 -0
  70. agent_scout_core-0.1.0/tests/test_price_monitor.py +84 -0
  71. agent_scout_core-0.1.0/tests/test_react_loop.py +359 -0
  72. agent_scout_core-0.1.0/tests/test_recovery.py +138 -0
  73. agent_scout_core-0.1.0/tests/test_resilience.py +89 -0
  74. agent_scout_core-0.1.0/tests/test_scheduler.py +134 -0
  75. agent_scout_core-0.1.0/tests/test_security.py +97 -0
  76. agent_scout_core-0.1.0/tests/test_settings.py +91 -0
  77. agent_scout_core-0.1.0/tests/test_task_manager.py +246 -0
  78. agent_scout_core-0.1.0/tests/test_tracer.py +121 -0
  79. agent_scout_core-0.1.0/uv.lock +2274 -0
@@ -0,0 +1,81 @@
1
+ # ===========================
2
+ # Python
3
+ # ===========================
4
+ __pycache__/
5
+ *.py[cod]
6
+ *$py.class
7
+ *.so
8
+ *.egg-info/
9
+ *.egg
10
+ dist/
11
+ build/
12
+ .eggs/
13
+ *.whl
14
+ .venv/
15
+ venv/
16
+ .uv/
17
+ .python-version
18
+
19
+ # ===========================
20
+ # Node.js / Next.js
21
+ # ===========================
22
+ node_modules/
23
+ .next/
24
+ out/
25
+ .turbo/
26
+ *.tsbuildinfo
27
+
28
+ # ===========================
29
+ # IDE / Editors
30
+ # ===========================
31
+ .vscode/
32
+ .idea/
33
+ *.swp
34
+ *.swo
35
+ *~
36
+ .DS_Store
37
+ Thumbs.db
38
+
39
+ # ===========================
40
+ # Environment / Secrets
41
+ # ===========================
42
+ .env
43
+ .env.local
44
+ .env.*.local
45
+ !.env.example
46
+
47
+ # ===========================
48
+ # Database
49
+ # ===========================
50
+ *.db
51
+ *.sqlite
52
+ *.sqlite3
53
+
54
+ # ===========================
55
+ # Logs
56
+ # ===========================
57
+ *.log
58
+ logs/
59
+
60
+ # ===========================
61
+ # Testing / Coverage
62
+ # ===========================
63
+ .coverage
64
+ htmlcov/
65
+ .pytest_cache/
66
+ coverage/
67
+ .nyc_output/
68
+
69
+ # ===========================
70
+ # OS
71
+ # ===========================
72
+ .DS_Store
73
+ Thumbs.db
74
+ ehthumbs.db
75
+ Desktop.ini
76
+
77
+ # ===========================
78
+ # Build artifacts
79
+ # ===========================
80
+ *.pyc
81
+ *.pyo
@@ -0,0 +1,41 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-scout-core
3
+ Version: 0.1.0
4
+ Summary: Core engine for AgentScout — a multi-purpose AI assistant agent
5
+ Project-URL: Homepage, https://github.com/ErnestHysa/agent-scout
6
+ Project-URL: Repository, https://github.com/ErnestHysa/agent-scout
7
+ Project-URL: Issues, https://github.com/ErnestHysa/agent-scout/issues
8
+ Author: Ernest Hysa
9
+ License-Expression: AGPL-3.0-or-later
10
+ Keywords: agent,ai,assistant,llm,scout
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
16
+ Requires-Python: >=3.12
17
+ Requires-Dist: aiosqlite>=0.20
18
+ Requires-Dist: apscheduler>=3.10
19
+ Requires-Dist: duckduckgo-search>=7
20
+ Requires-Dist: fastapi>=0.115
21
+ Requires-Dist: httpx>=0.28
22
+ Requires-Dist: litellm>=1.55
23
+ Requires-Dist: playwright>=1.49
24
+ Requires-Dist: pydantic-settings>=2.7
25
+ Requires-Dist: pydantic>=2.10
26
+ Requires-Dist: rich>=13
27
+ Requires-Dist: uvicorn[standard]>=0.34
28
+ Requires-Dist: websockets>=14
29
+ Provides-Extra: dev
30
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
31
+ Requires-Dist: pytest-cov>=6; extra == 'dev'
32
+ Requires-Dist: pytest-mock>=3; extra == 'dev'
33
+ Requires-Dist: pytest>=8; extra == 'dev'
34
+ Requires-Dist: ruff>=0.8; extra == 'dev'
35
+ Description-Content-Type: text/markdown
36
+
37
+ # agent-scout-core
38
+
39
+ Core engine for AgentScout — a multi-purpose AI assistant agent.
40
+
41
+ This package contains the ReAct reasoning loop, task management, LLM provider layer, plugin system, and all core logic.
@@ -0,0 +1,5 @@
1
+ # agent-scout-core
2
+
3
+ Core engine for AgentScout — a multi-purpose AI assistant agent.
4
+
5
+ This package contains the ReAct reasoning loop, task management, LLM provider layer, plugin system, and all core logic.
@@ -0,0 +1,69 @@
1
+ [project]
2
+ name = "agent-scout-core"
3
+ version = "0.1.0"
4
+ description = "Core engine for AgentScout — a multi-purpose AI assistant agent"
5
+ readme = "README.md"
6
+ license = "AGPL-3.0-or-later"
7
+ requires-python = ">=3.12"
8
+ authors = [{ name = "Ernest Hysa" }]
9
+ keywords = ["ai", "agent", "assistant", "scout", "llm"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Intended Audience :: Developers",
13
+ "License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
14
+ "Programming Language :: Python :: 3.12",
15
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
16
+ ]
17
+ dependencies = [
18
+ "litellm>=1.55",
19
+ "fastapi>=0.115",
20
+ "uvicorn[standard]>=0.34",
21
+ "websockets>=14",
22
+ "aiosqlite>=0.20",
23
+ "pydantic>=2.10",
24
+ "pydantic-settings>=2.7",
25
+ "apscheduler>=3.10",
26
+ "playwright>=1.49",
27
+ "duckduckgo-search>=7",
28
+ "httpx>=0.28",
29
+ "rich>=13",
30
+ ]
31
+
32
+ [project.optional-dependencies]
33
+ dev = [
34
+ "pytest>=8",
35
+ "pytest-asyncio>=0.24",
36
+ "pytest-cov>=6",
37
+ "pytest-mock>=3",
38
+ "ruff>=0.8",
39
+ ]
40
+
41
+ [project.urls]
42
+ Homepage = "https://github.com/ErnestHysa/agent-scout"
43
+ Repository = "https://github.com/ErnestHysa/agent-scout"
44
+ Issues = "https://github.com/ErnestHysa/agent-scout/issues"
45
+
46
+ [build-system]
47
+ requires = ["hatchling"]
48
+ build-backend = "hatchling.build"
49
+
50
+ [tool.hatch.build.targets.wheel]
51
+ packages = ["src/agent_scout"]
52
+
53
+ [tool.pytest.ini_options]
54
+ testpaths = ["tests"]
55
+ asyncio_mode = "auto"
56
+
57
+ [tool.ruff]
58
+ target-version = "py312"
59
+ line-length = 100
60
+
61
+ [tool.ruff.lint]
62
+ select = ["E", "F", "W", "I", "N", "UP", "B", "A", "SIM"]
63
+
64
+ [tool.coverage.run]
65
+ source = ["agent_scout"]
66
+
67
+ [tool.coverage.report]
68
+ fail_under = 0
69
+ show_missing = true
@@ -0,0 +1,3 @@
1
+ """AgentScout Core — A multi-purpose AI assistant agent engine."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,183 @@
1
+ """Main Agent class — orchestrates the ReAct loop and task lifecycle."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+
8
+ from agent_scout.db.models import AutonomyLevel, Task, TaskStatus
9
+ from agent_scout.events import Event, EventBus, EventType
10
+ from agent_scout.react_loop import (
11
+ ActionType,
12
+ LLMProvider,
13
+ ReActConfig,
14
+ ReActLoop,
15
+ ToolExecutor,
16
+ UserInteraction,
17
+ )
18
+
19
+
20
+ class Agent:
21
+ """The main AgentScout agent.
22
+
23
+ Accepts natural language tasks, manages the ReAct loop lifecycle,
24
+ handles autonomy levels, and emits structured events for live UI updates.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ llm: LLMProvider,
30
+ tools: ToolExecutor,
31
+ event_bus: EventBus,
32
+ user: UserInteraction | None = None,
33
+ autonomy: AutonomyLevel = AutonomyLevel.FULL,
34
+ max_iterations: int = 25,
35
+ ) -> None:
36
+ self._llm = llm
37
+ self._tools = tools
38
+ self._bus = event_bus
39
+ self._user = user
40
+ self._autonomy = autonomy
41
+ self._max_iterations = max_iterations
42
+ self._active_loops: dict[str, ReActLoop] = {}
43
+ self._lock = asyncio.Lock()
44
+
45
+ @property
46
+ def autonomy(self) -> AutonomyLevel:
47
+ return self._autonomy
48
+
49
+ @autonomy.setter
50
+ def autonomy(self, level: AutonomyLevel) -> None:
51
+ self._autonomy = level
52
+
53
+ async def run_task(self, task: Task) -> Task:
54
+ """Execute a task through the ReAct loop.
55
+
56
+ Updates the task status through its lifecycle and returns
57
+ the completed/failed task.
58
+ """
59
+ task.status = TaskStatus.PLANNING
60
+ task.updated_at = time.time()
61
+ await self._bus.emit(
62
+ Event(
63
+ event_type=EventType.TASK_STATE_CHANGE,
64
+ data={"status": task.status.value, "description": task.description},
65
+ task_id=task.id,
66
+ )
67
+ )
68
+
69
+ await self._bus.emit(
70
+ Event(
71
+ event_type=EventType.AGENT_STARTED,
72
+ data={
73
+ "task_id": task.id,
74
+ "description": task.description,
75
+ "autonomy": self._autonomy.value,
76
+ },
77
+ task_id=task.id,
78
+ )
79
+ )
80
+
81
+ # Determine effective user interaction based on autonomy
82
+ effective_user = self._get_effective_user(task)
83
+
84
+ config = ReActConfig(
85
+ max_iterations=self._max_iterations,
86
+ enable_reflection=True,
87
+ )
88
+
89
+ loop = ReActLoop(
90
+ llm=self._llm,
91
+ tools=self._tools,
92
+ event_bus=self._bus,
93
+ user=effective_user,
94
+ config=config,
95
+ )
96
+
97
+ async with self._lock:
98
+ self._active_loops[task.id] = loop
99
+
100
+ try:
101
+ task.status = TaskStatus.EXECUTING
102
+ task.updated_at = time.time()
103
+ await self._bus.emit(
104
+ Event(
105
+ event_type=EventType.TASK_STATE_CHANGE,
106
+ data={"status": task.status.value},
107
+ task_id=task.id,
108
+ )
109
+ )
110
+
111
+ steps = await loop.run(task.description, task_id=task.id)
112
+
113
+ # Determine outcome from final step
114
+ if steps and steps[-1].action.action_type == ActionType.FINISH:
115
+ task.status = TaskStatus.COMPLETED
116
+ task.result = steps[-1].action.result or {"message": steps[-1].action.message}
117
+ else:
118
+ task.status = TaskStatus.FAILED
119
+ task.error = "Max iterations reached without completion"
120
+
121
+ except Exception as e:
122
+ task.status = TaskStatus.FAILED
123
+ task.error = str(e)
124
+ await self._bus.emit(
125
+ Event(
126
+ event_type=EventType.ERROR,
127
+ data={"error": str(e)},
128
+ task_id=task.id,
129
+ )
130
+ )
131
+
132
+ finally:
133
+ async with self._lock:
134
+ self._active_loops.pop(task.id, None)
135
+
136
+ task.updated_at = time.time()
137
+ event_type = (
138
+ EventType.TASK_COMPLETED
139
+ if task.status == TaskStatus.COMPLETED
140
+ else EventType.TASK_FAILED
141
+ )
142
+ await self._bus.emit(
143
+ Event(
144
+ event_type=event_type,
145
+ data={
146
+ "status": task.status.value,
147
+ "result": task.result,
148
+ "error": task.error,
149
+ },
150
+ task_id=task.id,
151
+ )
152
+ )
153
+
154
+ await self._bus.emit(
155
+ Event(
156
+ event_type=EventType.AGENT_STOPPED,
157
+ data={"task_id": task.id},
158
+ task_id=task.id,
159
+ )
160
+ )
161
+
162
+ return task
163
+
164
+ def _get_effective_user(self, task: Task) -> UserInteraction | None:
165
+ """Determine the user interaction handler based on autonomy level."""
166
+ effective_autonomy = task.autonomy if task.autonomy else self._autonomy
167
+
168
+ if effective_autonomy == AutonomyLevel.FULL:
169
+ # In full autonomy, agent doesn't ask user (unless task explicitly requires it)
170
+ return None
171
+ # Semi and step_by_step both allow user interaction
172
+ return self._user
173
+
174
+ async def stop_task(self, task_id: str) -> None:
175
+ """Stop a running task."""
176
+ async with self._lock:
177
+ loop = self._active_loops.get(task_id)
178
+ if loop:
179
+ loop.stop()
180
+
181
+ @property
182
+ def active_task_ids(self) -> list[str]:
183
+ return list(self._active_loops.keys())
@@ -0,0 +1,246 @@
1
+ """Action approval system for tasks requiring user confirmation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ import uuid
7
+ from dataclasses import dataclass, field
8
+ from enum import StrEnum
9
+ from typing import Any
10
+
11
+ from agent_scout.events import Event, EventBus, EventType
12
+
13
+
14
+ class ApprovalStatus(StrEnum):
15
+ PENDING = "pending"
16
+ APPROVED = "approved"
17
+ REJECTED = "rejected"
18
+ MODIFIED = "modified"
19
+ EXPIRED = "expired"
20
+
21
+
22
+ @dataclass
23
+ class ApprovalRequest:
24
+ """A request for user approval of an agent action."""
25
+
26
+ id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
27
+ task_id: str = ""
28
+ action_type: str = "" # e.g. "purchase", "book", "send_email"
29
+ description: str = ""
30
+ estimated_cost: float = 0.0
31
+ details: dict[str, Any] = field(default_factory=dict)
32
+ status: ApprovalStatus = ApprovalStatus.PENDING
33
+ response_message: str = ""
34
+ modifications: dict[str, Any] = field(default_factory=dict)
35
+ created_at: float = field(default_factory=time.time)
36
+ resolved_at: float = 0.0
37
+ timeout_seconds: float = 300.0 # 5 minutes default
38
+
39
+
40
+ @dataclass
41
+ class AuditEntry:
42
+ """Audit log entry for an approval decision."""
43
+
44
+ request_id: str
45
+ task_id: str
46
+ action_type: str
47
+ status: ApprovalStatus
48
+ timestamp: float = field(default_factory=time.time)
49
+ details: dict[str, Any] = field(default_factory=dict)
50
+
51
+
52
+ class ApprovalManager:
53
+ """Manages action approval requests and audit logging.
54
+
55
+ When the agent wants to perform a real-world action (booking,
56
+ purchasing, etc.), it creates an approval request. The user
57
+ can approve, reject, or modify the request.
58
+ """
59
+
60
+ def __init__(self, event_bus: EventBus | None = None) -> None:
61
+ self._bus = event_bus
62
+ self._pending: dict[str, ApprovalRequest] = {}
63
+ self._audit_log: list[AuditEntry] = []
64
+
65
+ async def request_approval(
66
+ self,
67
+ task_id: str,
68
+ action_type: str,
69
+ description: str,
70
+ estimated_cost: float = 0.0,
71
+ details: dict[str, Any] | None = None,
72
+ timeout_seconds: float = 300.0,
73
+ ) -> ApprovalRequest:
74
+ """Create a new approval request and emit event."""
75
+ request = ApprovalRequest(
76
+ task_id=task_id,
77
+ action_type=action_type,
78
+ description=description,
79
+ estimated_cost=estimated_cost,
80
+ details=details or {},
81
+ timeout_seconds=timeout_seconds,
82
+ )
83
+
84
+ self._pending[request.id] = request
85
+
86
+ if self._bus:
87
+ await self._bus.emit(
88
+ Event(
89
+ event_type=EventType.USER_APPROVAL_REQUESTED,
90
+ data={
91
+ "request_id": request.id,
92
+ "action_type": action_type,
93
+ "description": description,
94
+ "estimated_cost": estimated_cost,
95
+ "details": details or {},
96
+ },
97
+ task_id=task_id,
98
+ )
99
+ )
100
+
101
+ return request
102
+
103
+ async def approve(
104
+ self,
105
+ request_id: str,
106
+ message: str = "",
107
+ ) -> ApprovalRequest | None:
108
+ """Approve a pending request."""
109
+ request = self._pending.get(request_id)
110
+ if not request or request.status != ApprovalStatus.PENDING:
111
+ return None
112
+
113
+ request.status = ApprovalStatus.APPROVED
114
+ request.response_message = message
115
+ request.resolved_at = time.time()
116
+
117
+ self._log_audit(request)
118
+ del self._pending[request_id]
119
+
120
+ if self._bus:
121
+ await self._bus.emit(
122
+ Event(
123
+ event_type=EventType.USER_APPROVAL_RECEIVED,
124
+ data={
125
+ "request_id": request_id,
126
+ "status": ApprovalStatus.APPROVED.value,
127
+ "message": message,
128
+ },
129
+ task_id=request.task_id,
130
+ )
131
+ )
132
+
133
+ return request
134
+
135
+ async def reject(
136
+ self,
137
+ request_id: str,
138
+ message: str = "",
139
+ ) -> ApprovalRequest | None:
140
+ """Reject a pending request."""
141
+ request = self._pending.get(request_id)
142
+ if not request or request.status != ApprovalStatus.PENDING:
143
+ return None
144
+
145
+ request.status = ApprovalStatus.REJECTED
146
+ request.response_message = message
147
+ request.resolved_at = time.time()
148
+
149
+ self._log_audit(request)
150
+ del self._pending[request_id]
151
+
152
+ if self._bus:
153
+ await self._bus.emit(
154
+ Event(
155
+ event_type=EventType.USER_APPROVAL_RECEIVED,
156
+ data={
157
+ "request_id": request_id,
158
+ "status": ApprovalStatus.REJECTED.value,
159
+ "message": message,
160
+ },
161
+ task_id=request.task_id,
162
+ )
163
+ )
164
+
165
+ return request
166
+
167
+ async def modify(
168
+ self,
169
+ request_id: str,
170
+ modifications: dict[str, Any],
171
+ message: str = "",
172
+ ) -> ApprovalRequest | None:
173
+ """Approve with modifications."""
174
+ request = self._pending.get(request_id)
175
+ if not request or request.status != ApprovalStatus.PENDING:
176
+ return None
177
+
178
+ request.status = ApprovalStatus.MODIFIED
179
+ request.modifications = modifications
180
+ request.response_message = message
181
+ request.resolved_at = time.time()
182
+
183
+ self._log_audit(request)
184
+ del self._pending[request_id]
185
+
186
+ if self._bus:
187
+ await self._bus.emit(
188
+ Event(
189
+ event_type=EventType.USER_APPROVAL_RECEIVED,
190
+ data={
191
+ "request_id": request_id,
192
+ "status": ApprovalStatus.MODIFIED.value,
193
+ "modifications": modifications,
194
+ "message": message,
195
+ },
196
+ task_id=request.task_id,
197
+ )
198
+ )
199
+
200
+ return request
201
+
202
+ def check_expired(self) -> list[ApprovalRequest]:
203
+ """Check for and expire timed-out requests."""
204
+ now = time.time()
205
+ expired: list[ApprovalRequest] = []
206
+
207
+ for req_id, request in list(self._pending.items()):
208
+ if now - request.created_at > request.timeout_seconds:
209
+ request.status = ApprovalStatus.EXPIRED
210
+ request.resolved_at = now
211
+ self._log_audit(request)
212
+ del self._pending[req_id]
213
+ expired.append(request)
214
+
215
+ return expired
216
+
217
+ def get_pending(self) -> list[ApprovalRequest]:
218
+ """Get all pending approval requests."""
219
+ return list(self._pending.values())
220
+
221
+ def get_audit_log(
222
+ self,
223
+ task_id: str | None = None,
224
+ limit: int = 50,
225
+ ) -> list[AuditEntry]:
226
+ """Get audit log entries, optionally filtered by task."""
227
+ entries = self._audit_log
228
+ if task_id:
229
+ entries = [e for e in entries if e.task_id == task_id]
230
+ return entries[-limit:]
231
+
232
+ def _log_audit(self, request: ApprovalRequest) -> None:
233
+ """Add an entry to the audit log."""
234
+ entry = AuditEntry(
235
+ request_id=request.id,
236
+ task_id=request.task_id,
237
+ action_type=request.action_type,
238
+ status=request.status,
239
+ details={
240
+ "description": request.description,
241
+ "estimated_cost": request.estimated_cost,
242
+ "response": request.response_message,
243
+ "modifications": request.modifications,
244
+ },
245
+ )
246
+ self._audit_log.append(entry)