gemcode 0.4.0__py3-none-any.whl → 0.4.2__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.
- gemcode/agent.py +85 -32
- gemcode/agent_habits.py +413 -0
- gemcode/agent_intelligence.py +474 -83
- gemcode/agent_mesh.py +125 -18
- gemcode/callbacks.py +34 -0
- gemcode/checkpoints.py +1 -1
- gemcode/codebase_awareness.py +368 -0
- gemcode/config.py +2 -2
- gemcode/curated_memory.py +1 -1
- gemcode/invoke.py +101 -95
- gemcode/learning.py +1 -1
- gemcode/model_routing.py +19 -0
- gemcode/plugins/terminal_hooks_plugin.py +1 -1
- gemcode/self_healing.py +303 -0
- gemcode/session_runtime.py +1 -1
- gemcode/tool_synthesis.py +234 -0
- gemcode/tools/__init__.py +14 -0
- gemcode/tools/curated_memory.py +1 -1
- gemcode/tools/edit.py +1 -1
- {gemcode-0.4.0.dist-info → gemcode-0.4.2.dist-info}/METADATA +102 -25
- {gemcode-0.4.0.dist-info → gemcode-0.4.2.dist-info}/RECORD +25 -21
- {gemcode-0.4.0.dist-info → gemcode-0.4.2.dist-info}/WHEEL +0 -0
- {gemcode-0.4.0.dist-info → gemcode-0.4.2.dist-info}/entry_points.txt +0 -0
- {gemcode-0.4.0.dist-info → gemcode-0.4.2.dist-info}/licenses/LICENSE +0 -0
- {gemcode-0.4.0.dist-info → gemcode-0.4.2.dist-info}/top_level.txt +0 -0
gemcode/agent.py
CHANGED
|
@@ -1287,37 +1287,90 @@ def build_root_agent(
|
|
|
1287
1287
|
sub_agents = []
|
|
1288
1288
|
if getattr(cfg, "enable_adk_agent_transfer", True) and _tools is None:
|
|
1289
1289
|
try:
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
)
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1290
|
+
from gemcode.org import list_members, resolve_fleet_root
|
|
1291
|
+
|
|
1292
|
+
fleet_root = resolve_fleet_root(cfg.project_root)
|
|
1293
|
+
members = list_members(fleet_root)
|
|
1294
|
+
|
|
1295
|
+
for m in members:
|
|
1296
|
+
# Build a specialized sub-agent for each org member
|
|
1297
|
+
member_tools = build_function_tools(cfg, include_subtask=False)
|
|
1298
|
+
|
|
1299
|
+
# Restrict tools based on member kind
|
|
1300
|
+
if m.kind == "subagent" and m.name in ("verifier", "reviewer"):
|
|
1301
|
+
# Verifiers don't need write tools
|
|
1302
|
+
member_tools = [t for t in member_tools if getattr(t, "__name__", "") not in (
|
|
1303
|
+
"write_file", "search_replace", "delete_file", "move_file",
|
|
1304
|
+
)]
|
|
1305
|
+
|
|
1306
|
+
# Build instruction from member's skill if available
|
|
1307
|
+
member_instruction = (
|
|
1308
|
+
f"You are {m.name} ({m.title}).\n"
|
|
1309
|
+
f"Role: {m.description or 'General assistant'}\n"
|
|
1310
|
+
f"Reports to: {m.reports_to or 'manager'}\n\n"
|
|
1311
|
+
"Complete assigned tasks concisely. Return structured JSON when possible.\n"
|
|
1312
|
+
)
|
|
1313
|
+
|
|
1314
|
+
# Load skill content if available
|
|
1315
|
+
if m.skill_name:
|
|
1316
|
+
try:
|
|
1317
|
+
skill = load_skill(fleet_root, m.skill_name)
|
|
1318
|
+
if skill is not None:
|
|
1319
|
+
member_instruction += "\n" + expand_skill_text(skill, arguments="", session_id="")
|
|
1320
|
+
except Exception:
|
|
1321
|
+
pass
|
|
1322
|
+
|
|
1323
|
+
member_agent = LlmAgent(
|
|
1324
|
+
name=m.name,
|
|
1325
|
+
model=getattr(cfg, "model_alt", None) or cfg.model,
|
|
1326
|
+
description=m.description or f"{m.name} — {m.title}",
|
|
1327
|
+
instruction=member_instruction,
|
|
1328
|
+
tools=member_tools,
|
|
1329
|
+
generate_content_config=gen_cfg,
|
|
1330
|
+
output_key=f"agent_{m.name}_result",
|
|
1331
|
+
**cb_kwargs,
|
|
1332
|
+
)
|
|
1333
|
+
sub_agents.append(member_agent)
|
|
1334
|
+
|
|
1335
|
+
# If no org members exist, create default explorer + verifier
|
|
1336
|
+
if not sub_agents:
|
|
1337
|
+
explorer_tools = build_function_tools(cfg, include_subtask=False)
|
|
1338
|
+
explorer_tools = [t for t in explorer_tools if getattr(t, "__name__", "") not in (
|
|
1339
|
+
"write_file", "search_replace", "delete_file", "move_file", "bash", "run_command",
|
|
1340
|
+
)]
|
|
1341
|
+
explorer = LlmAgent(
|
|
1342
|
+
name="explorer",
|
|
1343
|
+
model=getattr(cfg, "model_alt", None) or cfg.model,
|
|
1344
|
+
description="Quickly maps the codebase and finds relevant files/symbols.",
|
|
1345
|
+
instruction=(
|
|
1346
|
+
"You are Explorer. Your job is to quickly map the codebase and answer: "
|
|
1347
|
+
"what files/symbols matter and where to look next. Use read-only tools only. "
|
|
1348
|
+
"Return concise findings with file paths and symbol names."
|
|
1349
|
+
),
|
|
1350
|
+
tools=explorer_tools,
|
|
1351
|
+
generate_content_config=gen_cfg,
|
|
1352
|
+
output_key="explorer_result",
|
|
1353
|
+
**cb_kwargs,
|
|
1354
|
+
)
|
|
1355
|
+
verifier_tools = build_function_tools(cfg, include_subtask=False)
|
|
1356
|
+
verifier_tools = [t for t in verifier_tools if getattr(t, "__name__", "") not in (
|
|
1357
|
+
"write_file", "search_replace", "delete_file", "move_file",
|
|
1358
|
+
)]
|
|
1359
|
+
verifier = LlmAgent(
|
|
1360
|
+
name="verifier",
|
|
1361
|
+
model=getattr(cfg, "model_alt", None) or cfg.model,
|
|
1362
|
+
description="Verifies changes: runs checks/tests, spots inconsistencies, reports PASS/FAIL.",
|
|
1363
|
+
instruction=(
|
|
1364
|
+
"You are Verifier. Your job is to verify changes: run checks/tests when needed, "
|
|
1365
|
+
"spot inconsistencies, and report PASS/FAIL with concrete evidence. "
|
|
1366
|
+
"Prefer minimal commands and short outputs."
|
|
1367
|
+
),
|
|
1368
|
+
tools=verifier_tools,
|
|
1369
|
+
generate_content_config=gen_cfg,
|
|
1370
|
+
output_key="verifier_result",
|
|
1371
|
+
**cb_kwargs,
|
|
1372
|
+
)
|
|
1373
|
+
sub_agents = [explorer, verifier]
|
|
1321
1374
|
except Exception:
|
|
1322
1375
|
sub_agents = []
|
|
1323
1376
|
|
|
@@ -1327,8 +1380,8 @@ def build_root_agent(
|
|
|
1327
1380
|
instruction=build_instruction(cfg),
|
|
1328
1381
|
tools=tools,
|
|
1329
1382
|
generate_content_config=gen_cfg,
|
|
1330
|
-
# ADK expects a list; passing None can fail validation on some versions.
|
|
1331
1383
|
sub_agents=sub_agents,
|
|
1384
|
+
output_key="gemcode_last_output",
|
|
1332
1385
|
**cb_kwargs,
|
|
1333
1386
|
)
|
|
1334
1387
|
|
gemcode/agent_habits.py
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Agent Habits — Autonomous scheduled behaviors for agents.
|
|
3
|
+
|
|
4
|
+
Habits are recurring tasks that agents perform on a schedule without user
|
|
5
|
+
intervention. They run inside the Agent Mesh (no separate daemon needed).
|
|
6
|
+
|
|
7
|
+
Examples:
|
|
8
|
+
- "Every 30 minutes, check if tests still pass"
|
|
9
|
+
- "Every 2 hours, summarize what changed in the repo"
|
|
10
|
+
- "Nightly at 2am, run a full security audit"
|
|
11
|
+
- "Every 5 minutes, check for new issues in the tracker"
|
|
12
|
+
|
|
13
|
+
Habits are stored in `.gemcode/habits.json` and can be managed via tools
|
|
14
|
+
or the REPL. Each habit specifies:
|
|
15
|
+
- Which agent runs it (org member)
|
|
16
|
+
- What they do (prompt)
|
|
17
|
+
- When they do it (interval, cron, or daily)
|
|
18
|
+
- Whether they're enabled
|
|
19
|
+
|
|
20
|
+
The HabitScheduler runs as a background asyncio task inside the mesh,
|
|
21
|
+
polling habits and enqueuing work when due.
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
# From the agent (tools):
|
|
25
|
+
habits_add(name="test-watch", agent="kaira", prompt="Run pytest -q", every_minutes=30)
|
|
26
|
+
habits_add(name="nightly-audit", agent="verifier", prompt="Full security review", daily_at="02:00")
|
|
27
|
+
habits_list()
|
|
28
|
+
habits_remove("test-watch")
|
|
29
|
+
habits_pause("nightly-audit")
|
|
30
|
+
habits_resume("nightly-audit")
|
|
31
|
+
|
|
32
|
+
# From the filesystem:
|
|
33
|
+
# .gemcode/habits.json
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import asyncio
|
|
39
|
+
import json
|
|
40
|
+
import os
|
|
41
|
+
import time
|
|
42
|
+
from dataclasses import dataclass, field
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
from typing import Any
|
|
45
|
+
|
|
46
|
+
from gemcode.config import GemCodeConfig
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class Habit:
|
|
51
|
+
"""A recurring scheduled behavior for an agent."""
|
|
52
|
+
name: str
|
|
53
|
+
agent: str # org member name
|
|
54
|
+
prompt: str # what to do
|
|
55
|
+
enabled: bool = True
|
|
56
|
+
# Schedule (one of these should be set)
|
|
57
|
+
every_seconds: int | None = None # interval in seconds
|
|
58
|
+
cron: str | None = None # "M H * * *" (minute, hour)
|
|
59
|
+
daily_at: str | None = None # "HH:MM"
|
|
60
|
+
# Metadata
|
|
61
|
+
priority: int = 0
|
|
62
|
+
max_runs: int | None = None # None = unlimited
|
|
63
|
+
run_count: int = 0
|
|
64
|
+
last_run_ms: int = 0
|
|
65
|
+
created_ms: int = field(default_factory=lambda: int(time.time() * 1000))
|
|
66
|
+
|
|
67
|
+
def to_dict(self) -> dict[str, Any]:
|
|
68
|
+
return {
|
|
69
|
+
"name": self.name,
|
|
70
|
+
"agent": self.agent,
|
|
71
|
+
"prompt": self.prompt,
|
|
72
|
+
"enabled": self.enabled,
|
|
73
|
+
"every_seconds": self.every_seconds,
|
|
74
|
+
"cron": self.cron,
|
|
75
|
+
"daily_at": self.daily_at,
|
|
76
|
+
"priority": self.priority,
|
|
77
|
+
"max_runs": self.max_runs,
|
|
78
|
+
"run_count": self.run_count,
|
|
79
|
+
"last_run_ms": self.last_run_ms,
|
|
80
|
+
"created_ms": self.created_ms,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _habits_path(project_root: Path) -> Path:
|
|
85
|
+
return project_root / ".gemcode" / "habits.json"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def load_habits(project_root: Path) -> list[Habit]:
|
|
89
|
+
"""Load habits from .gemcode/habits.json."""
|
|
90
|
+
p = _habits_path(project_root)
|
|
91
|
+
if not p.is_file():
|
|
92
|
+
return []
|
|
93
|
+
try:
|
|
94
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
95
|
+
if not isinstance(data, dict):
|
|
96
|
+
return []
|
|
97
|
+
raw = data.get("habits", [])
|
|
98
|
+
if not isinstance(raw, list):
|
|
99
|
+
return []
|
|
100
|
+
out: list[Habit] = []
|
|
101
|
+
for item in raw:
|
|
102
|
+
if not isinstance(item, dict):
|
|
103
|
+
continue
|
|
104
|
+
name = str(item.get("name") or "").strip()
|
|
105
|
+
agent = str(item.get("agent") or "").strip()
|
|
106
|
+
prompt = str(item.get("prompt") or "").strip()
|
|
107
|
+
if not name or not agent or not prompt:
|
|
108
|
+
continue
|
|
109
|
+
out.append(Habit(
|
|
110
|
+
name=name,
|
|
111
|
+
agent=agent,
|
|
112
|
+
prompt=prompt,
|
|
113
|
+
enabled=bool(item.get("enabled", True)),
|
|
114
|
+
every_seconds=item.get("every_seconds"),
|
|
115
|
+
cron=item.get("cron"),
|
|
116
|
+
daily_at=item.get("daily_at"),
|
|
117
|
+
priority=int(item.get("priority") or 0),
|
|
118
|
+
max_runs=item.get("max_runs"),
|
|
119
|
+
run_count=int(item.get("run_count") or 0),
|
|
120
|
+
last_run_ms=int(item.get("last_run_ms") or 0),
|
|
121
|
+
created_ms=int(item.get("created_ms") or 0),
|
|
122
|
+
))
|
|
123
|
+
return out
|
|
124
|
+
except Exception:
|
|
125
|
+
return []
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def save_habits(project_root: Path, habits: list[Habit]) -> None:
|
|
129
|
+
"""Save habits to .gemcode/habits.json."""
|
|
130
|
+
p = _habits_path(project_root)
|
|
131
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
data = {"habits": [h.to_dict() for h in habits]}
|
|
133
|
+
p.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _is_due(habit: Habit, now_s: float) -> bool:
|
|
137
|
+
"""Check if a habit is due to run."""
|
|
138
|
+
if not habit.enabled:
|
|
139
|
+
return False
|
|
140
|
+
if habit.max_runs is not None and habit.run_count >= habit.max_runs:
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
last_s = habit.last_run_ms / 1000.0 if habit.last_run_ms else 0.0
|
|
144
|
+
|
|
145
|
+
# Interval-based
|
|
146
|
+
if habit.every_seconds and habit.every_seconds > 0:
|
|
147
|
+
if last_s == 0:
|
|
148
|
+
return True
|
|
149
|
+
return (now_s - last_s) >= float(habit.every_seconds)
|
|
150
|
+
|
|
151
|
+
# Daily at HH:MM
|
|
152
|
+
if habit.daily_at:
|
|
153
|
+
try:
|
|
154
|
+
hh, mm = habit.daily_at.split(":", 1)
|
|
155
|
+
h, m = int(hh), int(mm)
|
|
156
|
+
lt = time.localtime(now_s)
|
|
157
|
+
fire_today = time.mktime((lt.tm_year, lt.tm_mon, lt.tm_mday, h, m, 0, lt.tm_wday, lt.tm_yday, lt.tm_isdst))
|
|
158
|
+
if last_s == 0:
|
|
159
|
+
return now_s >= fire_today
|
|
160
|
+
return last_s < fire_today <= now_s
|
|
161
|
+
except Exception:
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
# Cron "M H * * *"
|
|
165
|
+
if habit.cron:
|
|
166
|
+
return _cron_due(now_s=now_s, last_s=last_s or None, cron=habit.cron)
|
|
167
|
+
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _cron_due(*, now_s: float, last_s: float | None, cron: str) -> bool:
|
|
172
|
+
"""Minimal cron: "M H * * *" with *, */N, or integer."""
|
|
173
|
+
parts = (cron or "").split()
|
|
174
|
+
if len(parts) != 5:
|
|
175
|
+
return False
|
|
176
|
+
m_s, h_s, dom, mon, dow = parts
|
|
177
|
+
if dom != "*" or mon != "*" or dow != "*":
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
def _match(field_str: str, val: int, *, min_v: int, max_v: int) -> bool:
|
|
181
|
+
if field_str == "*":
|
|
182
|
+
return True
|
|
183
|
+
if field_str.startswith("*/"):
|
|
184
|
+
try:
|
|
185
|
+
step = int(field_str[2:])
|
|
186
|
+
return step > 0 and (val - min_v) % step == 0
|
|
187
|
+
except Exception:
|
|
188
|
+
return False
|
|
189
|
+
try:
|
|
190
|
+
return int(field_str) == val and min_v <= int(field_str) <= max_v
|
|
191
|
+
except Exception:
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
lt = time.localtime(now_s)
|
|
195
|
+
if not (_match(m_s, lt.tm_min, min_v=0, max_v=59) and _match(h_s, lt.tm_hour, min_v=0, max_v=23)):
|
|
196
|
+
return False
|
|
197
|
+
minute_start = now_s - float(lt.tm_sec)
|
|
198
|
+
if last_s is None:
|
|
199
|
+
return True
|
|
200
|
+
return last_s < minute_start <= now_s
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class HabitScheduler:
|
|
204
|
+
"""
|
|
205
|
+
Background scheduler that polls habits and enqueues work on the mesh.
|
|
206
|
+
|
|
207
|
+
Runs as an asyncio task inside the mesh process. Checks every 10 seconds
|
|
208
|
+
for due habits and enqueues them as mesh jobs.
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
def __init__(self, cfg: GemCodeConfig) -> None:
|
|
212
|
+
self.cfg = cfg
|
|
213
|
+
self._task: asyncio.Task | None = None
|
|
214
|
+
self._stop = asyncio.Event()
|
|
215
|
+
self._poll_interval = float(os.environ.get("GEMCODE_HABITS_POLL_S", "10"))
|
|
216
|
+
|
|
217
|
+
def start(self) -> None:
|
|
218
|
+
"""Start the habit scheduler loop."""
|
|
219
|
+
if self._task is None or self._task.done():
|
|
220
|
+
self._task = asyncio.create_task(self._loop())
|
|
221
|
+
|
|
222
|
+
def stop(self) -> None:
|
|
223
|
+
"""Stop the scheduler."""
|
|
224
|
+
self._stop.set()
|
|
225
|
+
if self._task and not self._task.done():
|
|
226
|
+
self._task.cancel()
|
|
227
|
+
|
|
228
|
+
async def _loop(self) -> None:
|
|
229
|
+
"""Poll habits and enqueue due ones."""
|
|
230
|
+
while not self._stop.is_set():
|
|
231
|
+
try:
|
|
232
|
+
await self._check_and_fire()
|
|
233
|
+
except Exception:
|
|
234
|
+
pass
|
|
235
|
+
await asyncio.sleep(self._poll_interval)
|
|
236
|
+
|
|
237
|
+
async def _check_and_fire(self) -> None:
|
|
238
|
+
"""Check all habits and fire any that are due."""
|
|
239
|
+
if not _enabled():
|
|
240
|
+
return
|
|
241
|
+
|
|
242
|
+
habits = load_habits(self.cfg.project_root)
|
|
243
|
+
if not habits:
|
|
244
|
+
return
|
|
245
|
+
|
|
246
|
+
now_s = time.time()
|
|
247
|
+
changed = False
|
|
248
|
+
|
|
249
|
+
for habit in habits:
|
|
250
|
+
if not _is_due(habit, now_s):
|
|
251
|
+
continue
|
|
252
|
+
|
|
253
|
+
# Fire the habit
|
|
254
|
+
try:
|
|
255
|
+
from gemcode.agent_mesh import get_mesh
|
|
256
|
+
mesh = get_mesh(self.cfg)
|
|
257
|
+
if mesh is None:
|
|
258
|
+
continue
|
|
259
|
+
|
|
260
|
+
mesh.enqueue(
|
|
261
|
+
prompt=habit.prompt,
|
|
262
|
+
priority=habit.priority,
|
|
263
|
+
member_name=habit.agent,
|
|
264
|
+
meta={"habit": {"name": habit.name, "agent": habit.agent}},
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
# Update state
|
|
268
|
+
habit.last_run_ms = int(now_s * 1000)
|
|
269
|
+
habit.run_count += 1
|
|
270
|
+
changed = True
|
|
271
|
+
|
|
272
|
+
# Publish event
|
|
273
|
+
from gemcode.event_bus import BusMessage, get_bus
|
|
274
|
+
bus = get_bus()
|
|
275
|
+
await bus.publish(BusMessage(
|
|
276
|
+
topic="habit.fired",
|
|
277
|
+
from_addr="scheduler",
|
|
278
|
+
payload={"name": habit.name, "agent": habit.agent, "run_count": habit.run_count},
|
|
279
|
+
))
|
|
280
|
+
except Exception:
|
|
281
|
+
pass
|
|
282
|
+
|
|
283
|
+
if changed:
|
|
284
|
+
save_habits(self.cfg.project_root, habits)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _enabled() -> bool:
|
|
288
|
+
return os.environ.get("GEMCODE_AGENT_HABITS", "1").strip().lower() in (
|
|
289
|
+
"1", "true", "yes", "on",
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# ── Tools ──────────────────────────────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
def make_habits_tools(cfg: GemCodeConfig) -> list:
|
|
296
|
+
"""Build tools for managing agent habits."""
|
|
297
|
+
|
|
298
|
+
def habits_list() -> dict:
|
|
299
|
+
"""List all configured agent habits (scheduled recurring tasks)."""
|
|
300
|
+
habits = load_habits(cfg.project_root)
|
|
301
|
+
return {
|
|
302
|
+
"ok": True,
|
|
303
|
+
"habits": [h.to_dict() for h in habits],
|
|
304
|
+
"count": len(habits),
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
def habits_add(
|
|
308
|
+
name: str,
|
|
309
|
+
agent: str,
|
|
310
|
+
prompt: str,
|
|
311
|
+
every_minutes: int | None = None,
|
|
312
|
+
every_seconds: int | None = None,
|
|
313
|
+
daily_at: str | None = None,
|
|
314
|
+
cron: str | None = None,
|
|
315
|
+
priority: int = 0,
|
|
316
|
+
max_runs: int | None = None,
|
|
317
|
+
) -> dict:
|
|
318
|
+
"""
|
|
319
|
+
Add a recurring habit for an agent.
|
|
320
|
+
|
|
321
|
+
A habit is a scheduled task that runs automatically on a timer.
|
|
322
|
+
The agent wakes up, does the task, reports back, then sleeps until next time.
|
|
323
|
+
Habits run inside the main GemCode process — no separate daemon needed.
|
|
324
|
+
They fire as long as GemCode is open (REPL/TUI session).
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
name: Unique name for this habit (e.g., "test-watch", "nightly-audit").
|
|
328
|
+
agent: Org member name to run this (e.g., "kaira", "verifier").
|
|
329
|
+
Use "self" or "main" to run as the main GemCode agent.
|
|
330
|
+
prompt: What the agent should do each time it wakes up.
|
|
331
|
+
every_minutes: Run every N minutes (e.g., 30 = every half hour).
|
|
332
|
+
every_seconds: Run every N seconds (for fine-grained intervals).
|
|
333
|
+
daily_at: Run once daily at this time (e.g., "02:00", "14:30").
|
|
334
|
+
cron: Cron expression "M H * * *" (e.g., "0 */2 * * *" = every 2 hours).
|
|
335
|
+
priority: Job priority (higher = runs first when queue is busy).
|
|
336
|
+
max_runs: Stop after this many runs (None = unlimited).
|
|
337
|
+
|
|
338
|
+
Examples:
|
|
339
|
+
habits_add("test-watch", "kaira", "Run pytest -q and report", every_minutes=30)
|
|
340
|
+
habits_add("nightly-audit", "verifier", "Full security review", daily_at="02:00")
|
|
341
|
+
habits_add("hourly-status", "self", "Summarize what changed in the last hour", cron="0 * * * *")
|
|
342
|
+
"""
|
|
343
|
+
import re
|
|
344
|
+
nm = (name or "").strip().lower()
|
|
345
|
+
if not re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,63}", nm):
|
|
346
|
+
return {"ok": False, "error": "invalid name (lowercase, numbers, dashes, max 64 chars)"}
|
|
347
|
+
if not agent.strip():
|
|
348
|
+
return {"ok": False, "error": "agent is required"}
|
|
349
|
+
if not prompt.strip():
|
|
350
|
+
return {"ok": False, "error": "prompt is required"}
|
|
351
|
+
|
|
352
|
+
# Determine schedule
|
|
353
|
+
secs = None
|
|
354
|
+
if every_minutes:
|
|
355
|
+
secs = int(every_minutes) * 60
|
|
356
|
+
elif every_seconds:
|
|
357
|
+
secs = int(every_seconds)
|
|
358
|
+
|
|
359
|
+
if not secs and not daily_at and not cron:
|
|
360
|
+
return {"ok": False, "error": "must specify one of: every_minutes, every_seconds, daily_at, or cron"}
|
|
361
|
+
|
|
362
|
+
habits = load_habits(cfg.project_root)
|
|
363
|
+
# Remove existing with same name
|
|
364
|
+
habits = [h for h in habits if h.name != nm]
|
|
365
|
+
habits.append(Habit(
|
|
366
|
+
name=nm,
|
|
367
|
+
agent=agent.strip(),
|
|
368
|
+
prompt=prompt.strip(),
|
|
369
|
+
enabled=True,
|
|
370
|
+
every_seconds=secs,
|
|
371
|
+
daily_at=daily_at,
|
|
372
|
+
cron=cron,
|
|
373
|
+
priority=priority,
|
|
374
|
+
max_runs=max_runs,
|
|
375
|
+
))
|
|
376
|
+
save_habits(cfg.project_root, habits)
|
|
377
|
+
return {"ok": True, "name": nm, "agent": agent, "schedule": daily_at or cron or f"every {secs}s"}
|
|
378
|
+
|
|
379
|
+
def habits_remove(name: str) -> dict:
|
|
380
|
+
"""Remove a habit by name."""
|
|
381
|
+
habits = load_habits(cfg.project_root)
|
|
382
|
+
before = len(habits)
|
|
383
|
+
habits = [h for h in habits if h.name != name.strip().lower()]
|
|
384
|
+
save_habits(cfg.project_root, habits)
|
|
385
|
+
return {"ok": True, "removed": before - len(habits)}
|
|
386
|
+
|
|
387
|
+
def habits_pause(name: str) -> dict:
|
|
388
|
+
"""Pause a habit (stop it from firing until resumed)."""
|
|
389
|
+
habits = load_habits(cfg.project_root)
|
|
390
|
+
for h in habits:
|
|
391
|
+
if h.name == name.strip().lower():
|
|
392
|
+
h.enabled = False
|
|
393
|
+
save_habits(cfg.project_root, habits)
|
|
394
|
+
return {"ok": True, "name": h.name, "enabled": False}
|
|
395
|
+
return {"ok": False, "error": f"habit not found: {name}"}
|
|
396
|
+
|
|
397
|
+
def habits_resume(name: str) -> dict:
|
|
398
|
+
"""Resume a paused habit."""
|
|
399
|
+
habits = load_habits(cfg.project_root)
|
|
400
|
+
for h in habits:
|
|
401
|
+
if h.name == name.strip().lower():
|
|
402
|
+
h.enabled = True
|
|
403
|
+
save_habits(cfg.project_root, habits)
|
|
404
|
+
return {"ok": True, "name": h.name, "enabled": True}
|
|
405
|
+
return {"ok": False, "error": f"habit not found: {name}"}
|
|
406
|
+
|
|
407
|
+
habits_list.__name__ = "habits_list"
|
|
408
|
+
habits_add.__name__ = "habits_add"
|
|
409
|
+
habits_remove.__name__ = "habits_remove"
|
|
410
|
+
habits_pause.__name__ = "habits_pause"
|
|
411
|
+
habits_resume.__name__ = "habits_resume"
|
|
412
|
+
|
|
413
|
+
return [habits_list, habits_add, habits_remove, habits_pause, habits_resume]
|