geenii 0.4.0.dev4__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.
- geenii/__init__.py +0 -0
- geenii/agent/__init__.py +0 -0
- geenii/agent/base.py +68 -0
- geenii/agent/base_agent.py +340 -0
- geenii/agent/env.py +129 -0
- geenii/agent/info_commands.py +313 -0
- geenii/agent/registry.py +165 -0
- geenii/agent/tasks.py +1120 -0
- geenii/agent/utils.py +43 -0
- geenii/agents.py +179 -0
- geenii/ai.py +469 -0
- geenii/apps.py +277 -0
- geenii/bots.py +25 -0
- geenii/chat_models.py +168 -0
- geenii/cli/__init__.py +0 -0
- geenii/cli/agent_cli.py +218 -0
- geenii/cli/agents_cli.py +142 -0
- geenii/cli/ai_cli.py +145 -0
- geenii/cli/apps_cli.py +241 -0
- geenii/cli/cli_runner.py +83 -0
- geenii/cli/click_helper.py +17 -0
- geenii/cli/daemon_cli.py +113 -0
- geenii/cli/flow_cli.py +316 -0
- geenii/cli/info_cli.py +35 -0
- geenii/cli/main.py +134 -0
- geenii/cli/mcp_cli.py +199 -0
- geenii/cli/models_cli.py +35 -0
- geenii/cli/pm_cli.py +196 -0
- geenii/cli/projects_cli.py +39 -0
- geenii/cli/scheduler_cli.py +384 -0
- geenii/cli/skills_cli.py +90 -0
- geenii/cli/supervisor_cli.py +327 -0
- geenii/cli/tools_cli.py +78 -0
- geenii/config.py +250 -0
- geenii/console/__init__.py +0 -0
- geenii/core/__init__.py +0 -0
- geenii/core/process_tools.py +103 -0
- geenii/core/tasks.py +13 -0
- geenii/core/tools.py +108 -0
- geenii/daemon/__init__.py +11 -0
- geenii/daemon/client.py +76 -0
- geenii/daemon/host.py +218 -0
- geenii/daemon/server.py +237 -0
- geenii/datamodels.py +360 -0
- geenii/example_bots.py +112 -0
- geenii/flow/__init__.py +33 -0
- geenii/flow/flow.py +727 -0
- geenii/flow/processor/__init__.py +9 -0
- geenii/flow/processor/echo.py +27 -0
- geenii/flow/processor/execute_command.py +132 -0
- geenii/flow/processor/execute_pm_command.py +177 -0
- geenii/flow/processor/execute_script.py +55 -0
- geenii/flow/processor/read_file.py +53 -0
- geenii/flow/processor/read_http.py +58 -0
- geenii/flows.py +32 -0
- geenii/g.py +260 -0
- geenii/hitl.py +210 -0
- geenii/logs.py +65 -0
- geenii/mcp_client.py +203 -0
- geenii/mcp_config.py +184 -0
- geenii/memory.py +157 -0
- geenii/pm/__init__.py +0 -0
- geenii/pm/process_manager.py +534 -0
- geenii/pm/process_manager_client.py +78 -0
- geenii/pm/process_manager_service.py +39 -0
- geenii/proc.py +143 -0
- geenii/provider/__init__.py +0 -0
- geenii/provider/anthropic/__init__.py +0 -0
- geenii/provider/anthropic/provider.py +292 -0
- geenii/provider/fake/__init__.py +0 -0
- geenii/provider/fake/provider.py +121 -0
- geenii/provider/geenii/__init__.py +0 -0
- geenii/provider/geenii/provider.py +42 -0
- geenii/provider/hf/__init__.py +0 -0
- geenii/provider/hf/provider.py +66 -0
- geenii/provider/interfaces.py +116 -0
- geenii/provider/kokoro/__init__.py +0 -0
- geenii/provider/kokoro/provider.py +25 -0
- geenii/provider/ollama/__init__.py +0 -0
- geenii/provider/ollama/provider.py +591 -0
- geenii/provider/openai/__init__.py +0 -0
- geenii/provider/openai/provider.py +515 -0
- geenii/provider/whisper/__init__.py +0 -0
- geenii/provider/whisper/provider.py +21 -0
- geenii/provider/whisper/whispercpp_ext.py +143 -0
- geenii/sandbox.py +329 -0
- geenii/scheduler.py +575 -0
- geenii/server/__init__.py +0 -0
- geenii/server/agents_routes.py +334 -0
- geenii/server/ai_routes.py +283 -0
- geenii/server/chat_routes.py +221 -0
- geenii/server/chat_sessions.py +168 -0
- geenii/server/daemon_routes.py +37 -0
- geenii/server/flow_routes.py +3 -0
- geenii/server/mcp_routes.py +179 -0
- geenii/server/pm_routes.py +150 -0
- geenii/server/projects_routes.py +23 -0
- geenii/server/public_routes.py +16 -0
- geenii/server/router.py +52 -0
- geenii/server/scheduler_routes.py +312 -0
- geenii/server/security.py +10 -0
- geenii/server/server.py +315 -0
- geenii/server/skills_routes.py +70 -0
- geenii/server/stats_routes.py +33 -0
- geenii/server/tools_routes.py +29 -0
- geenii/server/web_runner.py +119 -0
- geenii/skills.py +223 -0
- geenii/supervisor.py +668 -0
- geenii/tool/__init__.py +0 -0
- geenii/tool/common.py +113 -0
- geenii/tool/computer.py +226 -0
- geenii/tool/mcp_tool.py +68 -0
- geenii/tool/python.py +122 -0
- geenii/tool/registry.py +197 -0
- geenii/tool/sandbox.py +111 -0
- geenii/tool/script.py +66 -0
- geenii/tool/tool_filter.py +144 -0
- geenii/tools.py +199 -0
- geenii/usage_stats.py +280 -0
- geenii/utils/__init__.py +0 -0
- geenii/utils/async_bridge.py +90 -0
- geenii/utils/asyncify.py +10 -0
- geenii/utils/cached.py +351 -0
- geenii/utils/json_util.py +43 -0
- geenii/utils/mdfile.py +81 -0
- geenii/utils/modelstore.py +258 -0
- geenii/utils/mongodb.py +55 -0
- geenii/utils/os_util.py +39 -0
- geenii/utils/redis.py +34 -0
- geenii/utils/system_util.py +183 -0
- geenii/utils/terminal_util.py +102 -0
- geenii/utils/toml_util.py +41 -0
- geenii/utils/tts_util.py +80 -0
- geenii-0.4.0.dev4.dist-info/METADATA +97 -0
- geenii-0.4.0.dev4.dist-info/RECORD +138 -0
- geenii-0.4.0.dev4.dist-info/WHEEL +4 -0
- geenii-0.4.0.dev4.dist-info/entry_points.txt +4 -0
- geenii-0.4.0.dev4.dist-info/licenses/LICENSE +21 -0
geenii/__init__.py
ADDED
|
File without changes
|
geenii/agent/__init__.py
ADDED
|
File without changes
|
geenii/agent/base.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import abc
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from geenii.agent.base_agent import BaseAgent
|
|
9
|
+
|
|
10
|
+
from geenii.chat_models import ContentPart, TextContent
|
|
11
|
+
from geenii.datamodels import ModelMessage
|
|
12
|
+
|
|
13
|
+
DEFAULT_AGENT_SYSTEM_PROMPT = """
|
|
14
|
+
You are an AI assistant that MUST use available tools when they are relevant.
|
|
15
|
+
|
|
16
|
+
CORE RULES:
|
|
17
|
+
|
|
18
|
+
- NEVER answer from internal knowledge if a tool exists that can provide up-to-date, factual, or external information.
|
|
19
|
+
- ALWAYS evaluate whether a tool should be used BEFORE generating a final response.
|
|
20
|
+
- If a suitable tool exists, you MUST call the tool instead of answering directly.
|
|
21
|
+
- Only produce a final natural-language answer AFTER tool results are returned.
|
|
22
|
+
- Follow the tool calling format EXACTLY.
|
|
23
|
+
- Once a tool result answers the question, reply to the user. Do not call tools again.
|
|
24
|
+
- Never repeat a tool call with the same arguments; the earlier result is still valid.
|
|
25
|
+
- If a tool returns an error, try at most one fix, then explain the problem to the user.
|
|
26
|
+
|
|
27
|
+
DECISION PROCESS:
|
|
28
|
+
|
|
29
|
+
Step 1 — Analyze user intent.
|
|
30
|
+
Step 2 — Check available tools.
|
|
31
|
+
Step 3 — If any tool can help, return a tool call with the appropriate arguments.
|
|
32
|
+
Step 4 — If no tools are relevant, answer the user's question directly.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class BaseTask(abc.ABC):
|
|
37
|
+
"""
|
|
38
|
+
Base class for a task in the agent's process.
|
|
39
|
+
A task represents a unit of work that yields messages (or follow-up tasks) as it runs.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
@abc.abstractmethod
|
|
43
|
+
def execute(self) -> Iterator[Any]:
|
|
44
|
+
"""
|
|
45
|
+
The run method should be implemented by subclasses to perform the task's work and yield messages or other content as needed.
|
|
46
|
+
"""
|
|
47
|
+
yield ModelMessage(role="assistant", content=[TextContent(text="Not implemented.")])
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class BaseAgentTask(BaseTask, abc.ABC):
|
|
51
|
+
"""
|
|
52
|
+
Base class for a agent task.
|
|
53
|
+
The agent tasks has a reference to the agent instance and can access its tools, skills, memory, etc. to perform its work.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, agent: BaseAgent):
|
|
57
|
+
self.agent = agent
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def message_to_prompt(message: str | list[ContentPart]) -> str | None:
|
|
61
|
+
if message is None:
|
|
62
|
+
return None
|
|
63
|
+
elif isinstance(message, str):
|
|
64
|
+
return message
|
|
65
|
+
elif isinstance(message, list):
|
|
66
|
+
return " ".join([content.to_text() for content in message])
|
|
67
|
+
else:
|
|
68
|
+
raise ValueError("Unsupported message format")
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import tempfile
|
|
5
|
+
import uuid
|
|
6
|
+
from collections import deque
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import pydantic
|
|
12
|
+
|
|
13
|
+
from geenii.agent.base import DEFAULT_AGENT_SYSTEM_PROMPT, BaseTask
|
|
14
|
+
from geenii.bots import BotInterface
|
|
15
|
+
from geenii.chat_models import ContentPart, TextContent
|
|
16
|
+
from geenii.config import (
|
|
17
|
+
DEFAULT_COMPLETION_MODEL,
|
|
18
|
+
GEENII_CACHE_DIR,
|
|
19
|
+
GEENII_MEMORY_ENGINE,
|
|
20
|
+
)
|
|
21
|
+
from geenii.datamodels import ModelMessage, SystemMessage, Usage
|
|
22
|
+
from geenii.hitl import HumanInTheLoopController, NoHumanInTheLoopController
|
|
23
|
+
from geenii.memory import (
|
|
24
|
+
ChatMemory,
|
|
25
|
+
FileChatMemory,
|
|
26
|
+
ShortTermChatMemory,
|
|
27
|
+
SqliteChatMemory,
|
|
28
|
+
)
|
|
29
|
+
from geenii.skills import SkillRegistry
|
|
30
|
+
from geenii.tool.registry import ToolRegistry
|
|
31
|
+
from geenii.utils.json_util import append_jsonl
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class BaseAgent(BotInterface, abc.ABC):
|
|
37
|
+
MAX_TASKS = 15 # maximum number of tasks to process in the queue to prevent infinite loops
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
name,
|
|
42
|
+
model: str | None = None,
|
|
43
|
+
system_prompt: str | None = None,
|
|
44
|
+
developer_prompt: str | None = None,
|
|
45
|
+
description: str | None = None,
|
|
46
|
+
allowed_tools: set[str] | None = None,
|
|
47
|
+
mcp_servers: set[str] | None = None,
|
|
48
|
+
context_id: str | None = None,
|
|
49
|
+
memory: ChatMemory | None = None,
|
|
50
|
+
cwd: str | None = None,
|
|
51
|
+
*,
|
|
52
|
+
env: dict[str, str] | None = None,
|
|
53
|
+
secret_keys: set[str] | None = None,
|
|
54
|
+
tool_registry: ToolRegistry | None = None,
|
|
55
|
+
skill_registry: SkillRegistry | None = None,
|
|
56
|
+
hitl: HumanInTheLoopController | None = None,
|
|
57
|
+
):
|
|
58
|
+
|
|
59
|
+
# configuration
|
|
60
|
+
self.name = name
|
|
61
|
+
self.description = description
|
|
62
|
+
self.model = model or DEFAULT_COMPLETION_MODEL
|
|
63
|
+
self.system_prompt = system_prompt or DEFAULT_AGENT_SYSTEM_PROMPT
|
|
64
|
+
self.developer_prompt = developer_prompt or ""
|
|
65
|
+
self.context_id = context_id or uuid.uuid7().hex
|
|
66
|
+
self.allowed_tools: set[str] = allowed_tools or set()
|
|
67
|
+
self.mcp_servers: set[str] = mcp_servers or set()
|
|
68
|
+
# environment passed to tool calls (vars and secrets); never exposed in to_dict()
|
|
69
|
+
self.env: dict[str, str] = env or {}
|
|
70
|
+
self.secret_keys: set[str] = secret_keys or set()
|
|
71
|
+
|
|
72
|
+
if not cwd:
|
|
73
|
+
# create a temp agent dir
|
|
74
|
+
self._temp_dir = tempfile.TemporaryDirectory(prefix=f"geenii_agent_{self.name}_")
|
|
75
|
+
cwd = self._temp_dir.name
|
|
76
|
+
self.cwd = cwd
|
|
77
|
+
|
|
78
|
+
# runtime
|
|
79
|
+
self.active_skills: list[str] = []
|
|
80
|
+
# usage of all model calls made by this agent instance (and agents it handed off to), see record_usage()
|
|
81
|
+
self.usage: Usage = Usage()
|
|
82
|
+
self.model_calls: int = 0
|
|
83
|
+
|
|
84
|
+
self._tool_registry = tool_registry or ToolRegistry()
|
|
85
|
+
self._skill_registry = skill_registry or SkillRegistry()
|
|
86
|
+
self._tasks: deque[BaseTask] = deque()
|
|
87
|
+
self._hitl = hitl or NoHumanInTheLoopController()
|
|
88
|
+
|
|
89
|
+
logger.info(f"Agent constructed with Context ID: {self.context_id}")
|
|
90
|
+
self.memory = memory or self._create_memory(self.context_id)
|
|
91
|
+
self._initialized = False
|
|
92
|
+
|
|
93
|
+
def __repr__(self):
|
|
94
|
+
return (
|
|
95
|
+
f"Agent(name={self.name}, context_id={self.context_id}, model={self.model}, "
|
|
96
|
+
f"tools={self.allowed_tools}, skills={self.skills.names()}, active_skills={self.active_skills})"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def _create_memory(self, context_id: str) -> ChatMemory:
|
|
100
|
+
if GEENII_MEMORY_ENGINE == "file":
|
|
101
|
+
path = os.path.join(GEENII_CACHE_DIR, "agents", self.name, f"memory.{context_id}.jsonl")
|
|
102
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
103
|
+
return FileChatMemory(path)
|
|
104
|
+
elif GEENII_MEMORY_ENGINE == "sqlite":
|
|
105
|
+
path = os.path.join(GEENII_CACHE_DIR, "agents", self.name, f"memory.{context_id}.db")
|
|
106
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
107
|
+
return SqliteChatMemory(path)
|
|
108
|
+
else:
|
|
109
|
+
return ShortTermChatMemory()
|
|
110
|
+
|
|
111
|
+
def _initialize(self):
|
|
112
|
+
"""Initialize the agent by loading built-in tools, MCP server tools, and any tools from loaded skills."""
|
|
113
|
+
|
|
114
|
+
def to_dict(self) -> dict:
|
|
115
|
+
return {
|
|
116
|
+
"context_id": self.context_id,
|
|
117
|
+
"name": self.name,
|
|
118
|
+
"description": self.description,
|
|
119
|
+
"model": self.model,
|
|
120
|
+
"system_prompt": self.system_prompt,
|
|
121
|
+
"developer_prompt": self.developer_prompt,
|
|
122
|
+
# "message_history": self.message_history,
|
|
123
|
+
"message_count": len(self.message_history),
|
|
124
|
+
"allowed_tools": list(self.allowed_tools),
|
|
125
|
+
"mcp_servers": list(self.mcp_servers),
|
|
126
|
+
"active_skills": list(self.active_skills),
|
|
127
|
+
"hitl_class": self._hitl.__class__.__name__,
|
|
128
|
+
"skills": list(self.skills.names()),
|
|
129
|
+
"tools": list(self.tools.list_tool_names()),
|
|
130
|
+
"usage": self.usage.model_dump(exclude_none=True),
|
|
131
|
+
"model_calls": self.model_calls,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
# def about_me(self):
|
|
135
|
+
# """Returns information about the agent, and it's configured tools and skills"""
|
|
136
|
+
# desc = ""
|
|
137
|
+
# desc += f"Name: {self.name}\n"
|
|
138
|
+
# desc += f"Description: {self.description}\n"
|
|
139
|
+
# desc += f"Model: {self.model}\n"
|
|
140
|
+
# desc += f"System prompt: {self.system_prompt}\n"
|
|
141
|
+
# desc += f"Developer: {self.developer_prompt}\n"
|
|
142
|
+
#
|
|
143
|
+
# desc += "Available tools:\n"
|
|
144
|
+
# for tool in self.tools.list_tools():
|
|
145
|
+
# desc += f"Tool: {tool.name} - {tool.description}\n"
|
|
146
|
+
#
|
|
147
|
+
# desc += "Skills:\n"
|
|
148
|
+
# for name, skill in self.skills.skills.items():
|
|
149
|
+
# desc += f"Skill: {name} - {skill.description}\n"
|
|
150
|
+
# return desc
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def tools(self) -> ToolRegistry:
|
|
154
|
+
return self._tool_registry
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def skills(self) -> SkillRegistry:
|
|
158
|
+
return self._skill_registry
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def hitl(self) -> HumanInTheLoopController:
|
|
162
|
+
return self._hitl
|
|
163
|
+
|
|
164
|
+
def record_usage(self, usage: Usage | None, calls: int = 1) -> None:
|
|
165
|
+
"""
|
|
166
|
+
Count model calls and their usage. Agent tasks call this for every successful completion
|
|
167
|
+
(`usage` may be None if the provider reported none); a handoff passes the sub-agent's totals.
|
|
168
|
+
The counters live as long as this agent instance; they are not persisted.
|
|
169
|
+
"""
|
|
170
|
+
self.model_calls += calls
|
|
171
|
+
if usage is not None:
|
|
172
|
+
self.usage = self.usage + usage
|
|
173
|
+
|
|
174
|
+
def enqueue_task(self, task: BaseTask):
|
|
175
|
+
"""Enqueue a task to be processed by the agent."""
|
|
176
|
+
self._tasks.append(task)
|
|
177
|
+
|
|
178
|
+
def clear_tasks(self):
|
|
179
|
+
"""Drop all pending tasks, e.g. after a turn was cancelled before the queue was drained."""
|
|
180
|
+
self._tasks.clear()
|
|
181
|
+
|
|
182
|
+
def process(self, message: str | list[ContentPart]) -> Iterator[ModelMessage]:
|
|
183
|
+
"""Process an incoming message and generate a response by enqueuing tasks to the internal queue and processing them sequentially."""
|
|
184
|
+
# lazy: runs once, after subclass __init__ has finished (subclasses may set attributes after super().__init__())
|
|
185
|
+
self._initialize()
|
|
186
|
+
_agent_log(self.name, self.context_id, "agent.init", self.to_dict())
|
|
187
|
+
_agent_log(self.name, self.context_id, "agent.message", {"message": message})
|
|
188
|
+
self._handle_message(message)
|
|
189
|
+
|
|
190
|
+
# process the queue and yield messages
|
|
191
|
+
yield from self._process_queue()
|
|
192
|
+
|
|
193
|
+
@abc.abstractmethod
|
|
194
|
+
def _handle_message(self, message: str | list[ContentPart]):
|
|
195
|
+
"""Handle an incoming prompt message by enqueuing an LLM task to generate a response."""
|
|
196
|
+
# self.enqueue_task(LLMTask(self, message=message))
|
|
197
|
+
# self.enqueue_task(FindBestAgentTask(self, prompt=message_to_prompt(message)))
|
|
198
|
+
...
|
|
199
|
+
|
|
200
|
+
def _process_queue(self) -> Iterator[ModelMessage]:
|
|
201
|
+
"""
|
|
202
|
+
Process tasks from the internal queue sequentially.
|
|
203
|
+
Yields content parts generated by the tasks.
|
|
204
|
+
Exits when the queue is empty.
|
|
205
|
+
"""
|
|
206
|
+
i = 0
|
|
207
|
+
while self._tasks:
|
|
208
|
+
i += 1
|
|
209
|
+
if i > self.MAX_TASKS:
|
|
210
|
+
# drop remaining tasks, otherwise the loop would never terminate
|
|
211
|
+
dropped = len(self._tasks)
|
|
212
|
+
self._tasks.clear()
|
|
213
|
+
logger.error(f"Maximum number of tasks reached: {self.MAX_TASKS}. Dropped {dropped} pending task(s).")
|
|
214
|
+
_agent_log(
|
|
215
|
+
self.name,
|
|
216
|
+
self.context_id,
|
|
217
|
+
"agent.max_tasks",
|
|
218
|
+
{"max_tasks": self.MAX_TASKS, "dropped": dropped},
|
|
219
|
+
)
|
|
220
|
+
yield ModelMessage(
|
|
221
|
+
role="assistant",
|
|
222
|
+
content=[TextContent(text=f"Maximum number of tasks reached: {self.MAX_TASKS}")],
|
|
223
|
+
)
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
task = self._tasks.popleft()
|
|
227
|
+
try:
|
|
228
|
+
logger.info(f"Task #{i}/{len(self._tasks)} {task.__class__.__name__} started.")
|
|
229
|
+
_agent_log(
|
|
230
|
+
self.name,
|
|
231
|
+
self.context_id,
|
|
232
|
+
"agent.task",
|
|
233
|
+
{"i": i, "task": task.__class__.__name__},
|
|
234
|
+
)
|
|
235
|
+
if isinstance(task, BaseTask):
|
|
236
|
+
for result in task.execute():
|
|
237
|
+
# parse task results
|
|
238
|
+
if result is None:
|
|
239
|
+
continue
|
|
240
|
+
if isinstance(result, (ModelMessage, SystemMessage)):
|
|
241
|
+
# _agent_log(self.name, "agent.task_result", {"i": i, "task": result.__class__.__name__, "result": result})
|
|
242
|
+
_agent_log(self.name, self.context_id, "agent.task_result", result)
|
|
243
|
+
yield result
|
|
244
|
+
elif isinstance(result, BaseTask):
|
|
245
|
+
# if the task yields another task, we enqueue it
|
|
246
|
+
self.enqueue_task(result)
|
|
247
|
+
continue
|
|
248
|
+
else:
|
|
249
|
+
logger.critical(f"Task {task} yielded an invalid message of type {type(result)}: {result}")
|
|
250
|
+
raise TypeError(f"Unsupported task response type: {type(result)}")
|
|
251
|
+
else:
|
|
252
|
+
logger.critical(f"Unsupported task type: {type(task)}")
|
|
253
|
+
raise TypeError(f"Unsupported task type: {type(task)}")
|
|
254
|
+
except Exception as e:
|
|
255
|
+
logger.exception(
|
|
256
|
+
f"Error processing task {task.__class__.__name__}: {e!s}",
|
|
257
|
+
exc_info=e,
|
|
258
|
+
)
|
|
259
|
+
# todo handle exceptions properly, e.g. by yielding an error message
|
|
260
|
+
yield ModelMessage(
|
|
261
|
+
role="assistant",
|
|
262
|
+
content=[TextContent(text=f"An error occurred while processing the task: {e!s}")],
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
def request_tool_execution(self, tool_name: str, arguments: dict, call_id: str) -> bool:
|
|
266
|
+
"""
|
|
267
|
+
This method can be overridden to implement custom logic for approving or rejecting tool execution requests.
|
|
268
|
+
By default, it approves all tool execution requests.
|
|
269
|
+
"""
|
|
270
|
+
_agent_log(
|
|
271
|
+
self.name,
|
|
272
|
+
self.context_id,
|
|
273
|
+
"agent.request_tool_execution",
|
|
274
|
+
{"tool_name": tool_name, "arguments": arguments, "call_id": call_id},
|
|
275
|
+
)
|
|
276
|
+
if self._hitl:
|
|
277
|
+
return self._hitl.request_tool_execution(tool_name, arguments, call_id)
|
|
278
|
+
return True
|
|
279
|
+
|
|
280
|
+
def load_skill(self, skill_name: str):
|
|
281
|
+
"""
|
|
282
|
+
Load a skill by name and add its tools to the agent's available tools.
|
|
283
|
+
"""
|
|
284
|
+
logger.warning(f"Using deprecated method Agent.load_skill(). Use 'Agent.skills.load({skill_name})' instead.")
|
|
285
|
+
_agent_log(self.name, self.context_id, "agent.load_skill", {"skill_name": skill_name})
|
|
286
|
+
self.skills.load(skill_name)
|
|
287
|
+
|
|
288
|
+
def unload_skill(self, skill_name: str):
|
|
289
|
+
"""
|
|
290
|
+
Unload a skill by name.
|
|
291
|
+
"""
|
|
292
|
+
logger.warning(
|
|
293
|
+
f"Using deprecated method Agent.unload_skill(). Use 'Agent.skills.unload({skill_name})' instead."
|
|
294
|
+
)
|
|
295
|
+
_agent_log(self.name, self.context_id, "agent.unload_skill", {"skill_name": skill_name})
|
|
296
|
+
self.skills.unload(skill_name)
|
|
297
|
+
|
|
298
|
+
@property
|
|
299
|
+
def message_history(self) -> list[ModelMessage]:
|
|
300
|
+
return self.memory.messages
|
|
301
|
+
|
|
302
|
+
def add_to_history(self, msg: ModelMessage):
|
|
303
|
+
self.memory.append(msg)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _agent_log(agent_name: str, context_id: str, event_name: str, data: dict | pydantic.BaseModel):
|
|
307
|
+
date_formatted = datetime.now().strftime("%Y-%m-%d")
|
|
308
|
+
# log_file = f"{CACHE_DIR}/logs/{agent_name}/logs/{agent_name}-{date_formatted}.log"
|
|
309
|
+
log_file = f"{GEENII_CACHE_DIR}/logs/{agent_name}-{date_formatted}.log"
|
|
310
|
+
Path(log_file).parent.mkdir(parents=True, exist_ok=True)
|
|
311
|
+
if isinstance(data, pydantic.BaseModel):
|
|
312
|
+
data = data.model_dump(mode="json")
|
|
313
|
+
append_jsonl(
|
|
314
|
+
log_file,
|
|
315
|
+
{
|
|
316
|
+
"agent": agent_name,
|
|
317
|
+
"context_id": context_id,
|
|
318
|
+
"event": event_name,
|
|
319
|
+
"data": data,
|
|
320
|
+
"timestamp": datetime.now().timestamp(),
|
|
321
|
+
},
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def restore_last_context_id(name: str):
|
|
326
|
+
file_path = f"{GEENII_CACHE_DIR}/agents/{name}/last_context_id"
|
|
327
|
+
try:
|
|
328
|
+
with open(file_path, "r") as f:
|
|
329
|
+
last_context_id = f.read().strip()
|
|
330
|
+
if last_context_id:
|
|
331
|
+
return last_context_id
|
|
332
|
+
except FileNotFoundError:
|
|
333
|
+
return None
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def dump_last_context_id(name: str, context_id):
|
|
337
|
+
file_path = f"{GEENII_CACHE_DIR}/agents/{name}/last_context_id"
|
|
338
|
+
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
|
339
|
+
with open(file_path, "w") as f:
|
|
340
|
+
f.write(str(context_id))
|
geenii/agent/env.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Per-agent environment variables and secrets.
|
|
3
|
+
|
|
4
|
+
Each agent has two dotenv files in the user's agent directory (whatever the agent's scope, so a committed
|
|
5
|
+
project directory never holds them):
|
|
6
|
+
|
|
7
|
+
- ``<name>.vars.env`` plain environment variables
|
|
8
|
+
- ``<name>.secrets.env`` sensitive environment variables (file mode 0600, values are never returned by the API)
|
|
9
|
+
|
|
10
|
+
Secrets are stored as plain text for now. Encryption would only change ``_read_secrets`` / ``_write_secrets``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import tempfile
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import dotenv
|
|
20
|
+
|
|
21
|
+
from geenii.utils.os_util import get_user_home_dir
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
ENV_KEY_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def validate_env_key(key: str) -> None:
|
|
29
|
+
if not ENV_KEY_PATTERN.match(key or ""):
|
|
30
|
+
raise ValueError(f"Invalid environment variable name '{key}'.")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AgentEnvStore:
|
|
34
|
+
def __init__(self, base_dir: str | Path | None = None):
|
|
35
|
+
self.base_dir = Path(base_dir) if base_dir else Path(get_user_home_dir()) / ".geenii" / "agents"
|
|
36
|
+
|
|
37
|
+
def vars_path(self, agent: str) -> Path:
|
|
38
|
+
return self.base_dir / f"{agent}.vars.env"
|
|
39
|
+
|
|
40
|
+
def secrets_path(self, agent: str) -> Path:
|
|
41
|
+
return self.base_dir / f"{agent}.secrets.env"
|
|
42
|
+
|
|
43
|
+
# vars
|
|
44
|
+
|
|
45
|
+
def get_vars(self, agent: str) -> dict[str, str]:
|
|
46
|
+
return self._read(self.vars_path(agent))
|
|
47
|
+
|
|
48
|
+
def set_vars(self, agent: str, values: dict[str, str]) -> None:
|
|
49
|
+
"""Replace all vars of the agent."""
|
|
50
|
+
for key in values:
|
|
51
|
+
validate_env_key(key)
|
|
52
|
+
self._write(self.vars_path(agent), values)
|
|
53
|
+
|
|
54
|
+
def set_var(self, agent: str, key: str, value: str) -> None:
|
|
55
|
+
self.set_vars(agent, {**self.get_vars(agent), key: value})
|
|
56
|
+
|
|
57
|
+
def delete_var(self, agent: str, key: str) -> bool:
|
|
58
|
+
values = self.get_vars(agent)
|
|
59
|
+
if key not in values:
|
|
60
|
+
return False
|
|
61
|
+
del values[key]
|
|
62
|
+
self.set_vars(agent, values)
|
|
63
|
+
return True
|
|
64
|
+
|
|
65
|
+
# secrets
|
|
66
|
+
|
|
67
|
+
def get_secrets(self, agent: str) -> dict[str, str]:
|
|
68
|
+
return self._read(self.secrets_path(agent))
|
|
69
|
+
|
|
70
|
+
def secret_keys(self, agent: str) -> list[str]:
|
|
71
|
+
return list(self.get_secrets(agent).keys())
|
|
72
|
+
|
|
73
|
+
def set_secret(self, agent: str, key: str, value: str) -> None:
|
|
74
|
+
validate_env_key(key)
|
|
75
|
+
self._write(
|
|
76
|
+
self.secrets_path(agent),
|
|
77
|
+
{**self.get_secrets(agent), key: value},
|
|
78
|
+
mode=0o600,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def delete_secret(self, agent: str, key: str) -> bool:
|
|
82
|
+
values = self.get_secrets(agent)
|
|
83
|
+
if key not in values:
|
|
84
|
+
return False
|
|
85
|
+
del values[key]
|
|
86
|
+
self._write(self.secrets_path(agent), values, mode=0o600)
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
def resolve(self, agent: str) -> dict[str, str]:
|
|
90
|
+
"""The agent's environment: vars, with secrets on top."""
|
|
91
|
+
return {**self.get_vars(agent), **self.get_secrets(agent)}
|
|
92
|
+
|
|
93
|
+
# file io
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
def _read(path: Path) -> dict[str, str]:
|
|
97
|
+
if not path.is_file():
|
|
98
|
+
return {}
|
|
99
|
+
try:
|
|
100
|
+
return {k: v or "" for k, v in dotenv.dotenv_values(path).items()}
|
|
101
|
+
except Exception as e:
|
|
102
|
+
logger.error(f"Could not read env file '{path}': {e}")
|
|
103
|
+
return {}
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def _write(path: Path, values: dict[str, str], mode: int = 0o644) -> None:
|
|
107
|
+
"""Writes the file atomically. An empty dict removes the file."""
|
|
108
|
+
if not values:
|
|
109
|
+
path.unlink(missing_ok=True)
|
|
110
|
+
return
|
|
111
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
|
|
113
|
+
try:
|
|
114
|
+
os.chmod(tmp, mode)
|
|
115
|
+
with os.fdopen(fd, "w") as f:
|
|
116
|
+
for key, value in values.items():
|
|
117
|
+
f.write(f"{key}={_quote(value)}\n")
|
|
118
|
+
os.replace(tmp, path)
|
|
119
|
+
except BaseException:
|
|
120
|
+
Path(tmp).unlink(missing_ok=True)
|
|
121
|
+
raise
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _quote(value: str) -> str:
|
|
125
|
+
# single quotes are taken literally by python-dotenv, unless the value contains one itself
|
|
126
|
+
if "'" not in value and "\n" not in value:
|
|
127
|
+
return f"'{value}'"
|
|
128
|
+
escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
|
|
129
|
+
return f'"{escaped}"'
|