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 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
- # Explorer: read-only, fast, low-risk. Keep instruction short.
1291
- explorer_tools = build_function_tools(cfg, include_subtask=False)
1292
- explorer_tools = [t for t in explorer_tools if getattr(t, "__name__", "") not in ("write_file", "search_replace", "delete_file", "move_file", "bash", "run_command")]
1293
- explorer = LlmAgent(
1294
- name="explorer",
1295
- model=getattr(cfg, "model_alt", None) or cfg.model,
1296
- instruction=(
1297
- "You are Explorer. Your job is to quickly map the codebase and answer: "
1298
- "what files/symbols matter and where to look next. Use read-only tools only. "
1299
- "Return concise findings with file paths and symbol names."
1300
- ),
1301
- tools=explorer_tools,
1302
- generate_content_config=gen_cfg,
1303
- **cb_kwargs,
1304
- )
1305
- # Verifier: focuses on checking, tests, and consistency.
1306
- verifier_tools = build_function_tools(cfg, include_subtask=False)
1307
- verifier_tools = [t for t in verifier_tools if getattr(t, "__name__", "") not in ("write_file", "search_replace", "delete_file", "move_file")]
1308
- verifier = LlmAgent(
1309
- name="verifier",
1310
- model=getattr(cfg, "model_alt", None) or cfg.model,
1311
- instruction=(
1312
- "You are Verifier. Your job is to verify changes: run checks/tests when needed, "
1313
- "spot inconsistencies, and report PASS/FAIL with concrete evidence. "
1314
- "Prefer minimal commands and short outputs."
1315
- ),
1316
- tools=verifier_tools,
1317
- generate_content_config=gen_cfg,
1318
- **cb_kwargs,
1319
- )
1320
- sub_agents = [explorer, verifier]
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
 
@@ -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]