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.
Files changed (138) hide show
  1. geenii/__init__.py +0 -0
  2. geenii/agent/__init__.py +0 -0
  3. geenii/agent/base.py +68 -0
  4. geenii/agent/base_agent.py +340 -0
  5. geenii/agent/env.py +129 -0
  6. geenii/agent/info_commands.py +313 -0
  7. geenii/agent/registry.py +165 -0
  8. geenii/agent/tasks.py +1120 -0
  9. geenii/agent/utils.py +43 -0
  10. geenii/agents.py +179 -0
  11. geenii/ai.py +469 -0
  12. geenii/apps.py +277 -0
  13. geenii/bots.py +25 -0
  14. geenii/chat_models.py +168 -0
  15. geenii/cli/__init__.py +0 -0
  16. geenii/cli/agent_cli.py +218 -0
  17. geenii/cli/agents_cli.py +142 -0
  18. geenii/cli/ai_cli.py +145 -0
  19. geenii/cli/apps_cli.py +241 -0
  20. geenii/cli/cli_runner.py +83 -0
  21. geenii/cli/click_helper.py +17 -0
  22. geenii/cli/daemon_cli.py +113 -0
  23. geenii/cli/flow_cli.py +316 -0
  24. geenii/cli/info_cli.py +35 -0
  25. geenii/cli/main.py +134 -0
  26. geenii/cli/mcp_cli.py +199 -0
  27. geenii/cli/models_cli.py +35 -0
  28. geenii/cli/pm_cli.py +196 -0
  29. geenii/cli/projects_cli.py +39 -0
  30. geenii/cli/scheduler_cli.py +384 -0
  31. geenii/cli/skills_cli.py +90 -0
  32. geenii/cli/supervisor_cli.py +327 -0
  33. geenii/cli/tools_cli.py +78 -0
  34. geenii/config.py +250 -0
  35. geenii/console/__init__.py +0 -0
  36. geenii/core/__init__.py +0 -0
  37. geenii/core/process_tools.py +103 -0
  38. geenii/core/tasks.py +13 -0
  39. geenii/core/tools.py +108 -0
  40. geenii/daemon/__init__.py +11 -0
  41. geenii/daemon/client.py +76 -0
  42. geenii/daemon/host.py +218 -0
  43. geenii/daemon/server.py +237 -0
  44. geenii/datamodels.py +360 -0
  45. geenii/example_bots.py +112 -0
  46. geenii/flow/__init__.py +33 -0
  47. geenii/flow/flow.py +727 -0
  48. geenii/flow/processor/__init__.py +9 -0
  49. geenii/flow/processor/echo.py +27 -0
  50. geenii/flow/processor/execute_command.py +132 -0
  51. geenii/flow/processor/execute_pm_command.py +177 -0
  52. geenii/flow/processor/execute_script.py +55 -0
  53. geenii/flow/processor/read_file.py +53 -0
  54. geenii/flow/processor/read_http.py +58 -0
  55. geenii/flows.py +32 -0
  56. geenii/g.py +260 -0
  57. geenii/hitl.py +210 -0
  58. geenii/logs.py +65 -0
  59. geenii/mcp_client.py +203 -0
  60. geenii/mcp_config.py +184 -0
  61. geenii/memory.py +157 -0
  62. geenii/pm/__init__.py +0 -0
  63. geenii/pm/process_manager.py +534 -0
  64. geenii/pm/process_manager_client.py +78 -0
  65. geenii/pm/process_manager_service.py +39 -0
  66. geenii/proc.py +143 -0
  67. geenii/provider/__init__.py +0 -0
  68. geenii/provider/anthropic/__init__.py +0 -0
  69. geenii/provider/anthropic/provider.py +292 -0
  70. geenii/provider/fake/__init__.py +0 -0
  71. geenii/provider/fake/provider.py +121 -0
  72. geenii/provider/geenii/__init__.py +0 -0
  73. geenii/provider/geenii/provider.py +42 -0
  74. geenii/provider/hf/__init__.py +0 -0
  75. geenii/provider/hf/provider.py +66 -0
  76. geenii/provider/interfaces.py +116 -0
  77. geenii/provider/kokoro/__init__.py +0 -0
  78. geenii/provider/kokoro/provider.py +25 -0
  79. geenii/provider/ollama/__init__.py +0 -0
  80. geenii/provider/ollama/provider.py +591 -0
  81. geenii/provider/openai/__init__.py +0 -0
  82. geenii/provider/openai/provider.py +515 -0
  83. geenii/provider/whisper/__init__.py +0 -0
  84. geenii/provider/whisper/provider.py +21 -0
  85. geenii/provider/whisper/whispercpp_ext.py +143 -0
  86. geenii/sandbox.py +329 -0
  87. geenii/scheduler.py +575 -0
  88. geenii/server/__init__.py +0 -0
  89. geenii/server/agents_routes.py +334 -0
  90. geenii/server/ai_routes.py +283 -0
  91. geenii/server/chat_routes.py +221 -0
  92. geenii/server/chat_sessions.py +168 -0
  93. geenii/server/daemon_routes.py +37 -0
  94. geenii/server/flow_routes.py +3 -0
  95. geenii/server/mcp_routes.py +179 -0
  96. geenii/server/pm_routes.py +150 -0
  97. geenii/server/projects_routes.py +23 -0
  98. geenii/server/public_routes.py +16 -0
  99. geenii/server/router.py +52 -0
  100. geenii/server/scheduler_routes.py +312 -0
  101. geenii/server/security.py +10 -0
  102. geenii/server/server.py +315 -0
  103. geenii/server/skills_routes.py +70 -0
  104. geenii/server/stats_routes.py +33 -0
  105. geenii/server/tools_routes.py +29 -0
  106. geenii/server/web_runner.py +119 -0
  107. geenii/skills.py +223 -0
  108. geenii/supervisor.py +668 -0
  109. geenii/tool/__init__.py +0 -0
  110. geenii/tool/common.py +113 -0
  111. geenii/tool/computer.py +226 -0
  112. geenii/tool/mcp_tool.py +68 -0
  113. geenii/tool/python.py +122 -0
  114. geenii/tool/registry.py +197 -0
  115. geenii/tool/sandbox.py +111 -0
  116. geenii/tool/script.py +66 -0
  117. geenii/tool/tool_filter.py +144 -0
  118. geenii/tools.py +199 -0
  119. geenii/usage_stats.py +280 -0
  120. geenii/utils/__init__.py +0 -0
  121. geenii/utils/async_bridge.py +90 -0
  122. geenii/utils/asyncify.py +10 -0
  123. geenii/utils/cached.py +351 -0
  124. geenii/utils/json_util.py +43 -0
  125. geenii/utils/mdfile.py +81 -0
  126. geenii/utils/modelstore.py +258 -0
  127. geenii/utils/mongodb.py +55 -0
  128. geenii/utils/os_util.py +39 -0
  129. geenii/utils/redis.py +34 -0
  130. geenii/utils/system_util.py +183 -0
  131. geenii/utils/terminal_util.py +102 -0
  132. geenii/utils/toml_util.py +41 -0
  133. geenii/utils/tts_util.py +80 -0
  134. geenii-0.4.0.dev4.dist-info/METADATA +97 -0
  135. geenii-0.4.0.dev4.dist-info/RECORD +138 -0
  136. geenii-0.4.0.dev4.dist-info/WHEEL +4 -0
  137. geenii-0.4.0.dev4.dist-info/entry_points.txt +4 -0
  138. geenii-0.4.0.dev4.dist-info/licenses/LICENSE +21 -0
geenii/__init__.py ADDED
File without changes
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}"'