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.
- agent_scout_core-0.1.0/.gitignore +81 -0
- agent_scout_core-0.1.0/PKG-INFO +41 -0
- agent_scout_core-0.1.0/README.md +5 -0
- agent_scout_core-0.1.0/pyproject.toml +69 -0
- agent_scout_core-0.1.0/src/agent_scout/__init__.py +3 -0
- agent_scout_core-0.1.0/src/agent_scout/agent.py +183 -0
- agent_scout_core-0.1.0/src/agent_scout/approval.py +246 -0
- agent_scout_core-0.1.0/src/agent_scout/comparison.py +256 -0
- agent_scout_core-0.1.0/src/agent_scout/config/__init__.py +0 -0
- agent_scout_core-0.1.0/src/agent_scout/config/settings.py +192 -0
- agent_scout_core-0.1.0/src/agent_scout/db/__init__.py +0 -0
- agent_scout_core-0.1.0/src/agent_scout/db/database.py +398 -0
- agent_scout_core-0.1.0/src/agent_scout/db/migrations/001_initial.sql +96 -0
- agent_scout_core-0.1.0/src/agent_scout/db/models.py +95 -0
- agent_scout_core-0.1.0/src/agent_scout/events.py +150 -0
- agent_scout_core-0.1.0/src/agent_scout/export.py +210 -0
- agent_scout_core-0.1.0/src/agent_scout/llm/__init__.py +0 -0
- agent_scout_core-0.1.0/src/agent_scout/llm/budget.py +211 -0
- agent_scout_core-0.1.0/src/agent_scout/llm/cache.py +149 -0
- agent_scout_core-0.1.0/src/agent_scout/llm/estimator.py +186 -0
- agent_scout_core-0.1.0/src/agent_scout/llm/provider.py +343 -0
- agent_scout_core-0.1.0/src/agent_scout/llm/router.py +260 -0
- agent_scout_core-0.1.0/src/agent_scout/logging/__init__.py +0 -0
- agent_scout_core-0.1.0/src/agent_scout/logging/tracer.py +58 -0
- agent_scout_core-0.1.0/src/agent_scout/memory/__init__.py +0 -0
- agent_scout_core-0.1.0/src/agent_scout/memory/store.py +87 -0
- agent_scout_core-0.1.0/src/agent_scout/personality/__init__.py +0 -0
- agent_scout_core-0.1.0/src/agent_scout/personality/personality.py +128 -0
- agent_scout_core-0.1.0/src/agent_scout/plugins/__init__.py +0 -0
- agent_scout_core-0.1.0/src/agent_scout/plugins/base.py +169 -0
- agent_scout_core-0.1.0/src/agent_scout/plugins/browser.py +271 -0
- agent_scout_core-0.1.0/src/agent_scout/plugins/exporter.py +251 -0
- agent_scout_core-0.1.0/src/agent_scout/plugins/mcp_bridge.py +179 -0
- agent_scout_core-0.1.0/src/agent_scout/plugins/notifier.py +313 -0
- agent_scout_core-0.1.0/src/agent_scout/plugins/price_tracker.py +240 -0
- agent_scout_core-0.1.0/src/agent_scout/plugins/registry.py +169 -0
- agent_scout_core-0.1.0/src/agent_scout/plugins/web_search.py +335 -0
- agent_scout_core-0.1.0/src/agent_scout/price_monitor.py +291 -0
- agent_scout_core-0.1.0/src/agent_scout/react_loop.py +269 -0
- agent_scout_core-0.1.0/src/agent_scout/recovery.py +281 -0
- agent_scout_core-0.1.0/src/agent_scout/resilience.py +126 -0
- agent_scout_core-0.1.0/src/agent_scout/scheduler/__init__.py +0 -0
- agent_scout_core-0.1.0/src/agent_scout/scheduler/scheduler.py +241 -0
- agent_scout_core-0.1.0/src/agent_scout/security.py +72 -0
- agent_scout_core-0.1.0/src/agent_scout/task_manager.py +177 -0
- agent_scout_core-0.1.0/tests/__init__.py +0 -0
- agent_scout_core-0.1.0/tests/conftest.py +54 -0
- agent_scout_core-0.1.0/tests/test_agent.py +261 -0
- agent_scout_core-0.1.0/tests/test_approval.py +188 -0
- agent_scout_core-0.1.0/tests/test_cache.py +121 -0
- agent_scout_core-0.1.0/tests/test_comparison.py +168 -0
- agent_scout_core-0.1.0/tests/test_database.py +265 -0
- agent_scout_core-0.1.0/tests/test_events.py +155 -0
- agent_scout_core-0.1.0/tests/test_export.py +116 -0
- agent_scout_core-0.1.0/tests/test_integration.py +333 -0
- agent_scout_core-0.1.0/tests/test_llm_budget.py +218 -0
- agent_scout_core-0.1.0/tests/test_llm_estimator.py +152 -0
- agent_scout_core-0.1.0/tests/test_llm_provider.py +253 -0
- agent_scout_core-0.1.0/tests/test_llm_router.py +139 -0
- agent_scout_core-0.1.0/tests/test_memory.py +126 -0
- agent_scout_core-0.1.0/tests/test_personality.py +106 -0
- agent_scout_core-0.1.0/tests/test_plugin_base.py +204 -0
- agent_scout_core-0.1.0/tests/test_plugin_browser.py +200 -0
- agent_scout_core-0.1.0/tests/test_plugin_exporter.py +146 -0
- agent_scout_core-0.1.0/tests/test_plugin_mcp_bridge.py +177 -0
- agent_scout_core-0.1.0/tests/test_plugin_notifier.py +201 -0
- agent_scout_core-0.1.0/tests/test_plugin_price_tracker.py +150 -0
- agent_scout_core-0.1.0/tests/test_plugin_registry.py +269 -0
- agent_scout_core-0.1.0/tests/test_plugin_web_search.py +191 -0
- agent_scout_core-0.1.0/tests/test_price_monitor.py +84 -0
- agent_scout_core-0.1.0/tests/test_react_loop.py +359 -0
- agent_scout_core-0.1.0/tests/test_recovery.py +138 -0
- agent_scout_core-0.1.0/tests/test_resilience.py +89 -0
- agent_scout_core-0.1.0/tests/test_scheduler.py +134 -0
- agent_scout_core-0.1.0/tests/test_security.py +97 -0
- agent_scout_core-0.1.0/tests/test_settings.py +91 -0
- agent_scout_core-0.1.0/tests/test_task_manager.py +246 -0
- agent_scout_core-0.1.0/tests/test_tracer.py +121 -0
- 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,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,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)
|