Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ clean:

# Syntax check
lint:
$(VENV_PYTHON) -m compileall -q main.py main_debug.py server.py tools.py memory.py event_store.py task_engine.py learning_engine.py learning_benchmark.py migration.py scheduler.py skill_registry.py browser_manager.py instruction_loader.py agent_config.py subagents.py capability_tokens.py tool_registry.py dynamic_tools.py plugin_runtime.py workspace_context.py scripts/generate_tool_inventory.py scripts/check_docs.py scripts/check_zsh_extras.py scripts/wheel_smoke.py
$(VENV_PYTHON) -m compileall -q main.py main_debug.py server.py tools.py memory.py event_store.py task_engine.py learning_engine.py learning_benchmark.py learning_worker.py migration.py scheduler.py skill_registry.py browser_manager.py instruction_loader.py agent_config.py subagents.py capability_tokens.py tool_registry.py dynamic_tools.py plugin_runtime.py workspace_context.py scripts/generate_tool_inventory.py scripts/check_docs.py scripts/check_zsh_extras.py scripts/wheel_smoke.py
@echo "Python syntax OK."
@echo "All files pass syntax check."

Expand Down Expand Up @@ -104,7 +104,7 @@ docker-smoke:
# Quick verification
check:
@echo "Checking Python syntax..."
@$(VENV_PYTHON) -m py_compile main.py main_debug.py server.py tools.py memory.py event_store.py task_engine.py learning_engine.py learning_benchmark.py migration.py scheduler.py skill_registry.py browser_manager.py instruction_loader.py agent_config.py subagents.py capability_tokens.py tool_registry.py dynamic_tools.py plugin_runtime.py workspace_context.py scripts/generate_tool_inventory.py scripts/check_docs.py scripts/check_zsh_extras.py scripts/wheel_smoke.py
@$(VENV_PYTHON) -m py_compile main.py main_debug.py server.py tools.py memory.py event_store.py task_engine.py learning_engine.py learning_benchmark.py learning_worker.py migration.py scheduler.py skill_registry.py browser_manager.py instruction_loader.py agent_config.py subagents.py capability_tokens.py tool_registry.py dynamic_tools.py plugin_runtime.py workspace_context.py scripts/generate_tool_inventory.py scripts/check_docs.py scripts/check_zsh_extras.py scripts/wheel_smoke.py
@echo " Python modules: OK"
@echo "Checking git tools..."
@$(VENV_PYTHON) -c "from tools import AVAILABLE_TOOLS; git = [k for k in AVAILABLE_TOOLS if k.startswith('git_')]; print(f' {len(git)} git tools, {len(AVAILABLE_TOOLS)} total tools')"
Expand Down
2 changes: 1 addition & 1 deletion README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,7 @@ extra をインストールしてください。

### 仕組み

CLI はアイドル時に 30 秒ごとに最大 4 機能をラウンドロビンで実行します。Web/Gateway は永続化された `learning_cycle` ジョブを使い、チャットターンでは入力依存の好み検出と技術検出も実行します。すべての機能は `/self-learning` で個別に切り替えられ、`GET /api/v2/learning/features` で最新状態を確認できます。入力や証拠がない場合は、変更なしの有界な no-op として記録されます。
CLI はワークスペースごとに単一の分離学習 worker を起動します。CLI がアクティブな間は worker が待機し、ターミナル終了後に心拍が 60 秒以上静かになると、30 秒ごとに最大 4 機能をラウンドロビンで実行します。Web/Gateway は永続化された `learning_cycle` ジョブを使い、チャットターンでは入力依存の好み検出と技術検出も実行します。すべての機能は `/self-learning` で個別に切り替えられ、設定は SQLite に保存されます。`GET /api/v2/learning/features` で最新状態を確認できます。入力や証拠がない場合は、変更なしの有界な no-op として記録されます。

| # | 機能 | 学習内容 |
|---|------|---------|
Expand Down
2 changes: 1 addition & 1 deletion README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,7 @@ extra를 설치하세요.

### 작동 방식

CLI는 유휴 상태에서 30초마다 최대 4개 기능을 라운드 로빈으로 실행합니다. Web/Gateway는 영속적인 `learning_cycle` 스케줄러 작업을 사용하고, 채팅 턴에서는 입력에 의존하는 선호도 및 기술 감지도 실행합니다. 모든 기능은 `/self-learning`으로 개별 전환할 수 있으며 `GET /api/v2/learning/features`에서 최신 상태를 확인할 수 있습니다. 입력이나 증거가 없으면 변경 없음인 제한된 no-op으로 기록됩니다.
CLI는 작업공간마다 단일 분리 학습 worker를 시작합니다. CLI가 활성 상태인 동안 worker는 대기하고, 터미널이 종료된 뒤 heartbeat가 60초 이상 조용해지면 30초마다 최대 4개 기능을 라운드 로빈으로 실행합니다. Web/Gateway는 영속적인 `learning_cycle` 스케줄러 작업을 사용하고, 채팅 턴에서는 입력에 의존하는 선호도 및 기술 감지도 실행합니다. 모든 기능은 `/self-learning`으로 개별 전환할 수 있고 설정은 SQLite에 저장되며 `GET /api/v2/learning/features`에서 최신 상태를 확인할 수 있습니다. 입력이나 증거가 없으면 변경 없음인 제한된 no-op으로 기록됩니다.

| # | 기능 | 학습 내용 |
|---|------|---------|
Expand Down
18 changes: 10 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,14 +301,15 @@ stores `learning.feature_started`, `learning.feature_completed`, or
whether it changed durable or in-memory state; `changed: false` is an honest
no-op when the required evidence or input is absent.

The CLI dispatches up to four features every 30 seconds while idle. Web and
Gateway processes use the durable `learning_cycle` scheduler job. Chat turns
also dispatch the input-dependent preference and technology features. The
same dispatcher and feature flags are used by all surfaces. Inspect the
latest status with `GET /api/v2/learning/features` or toggle flags with
`/self-learning`. Dynamic tools remain response-time operations protected by
explicit capability and approval gates, and rollback remains user-directed via
`/forget` or an explicit learning rollback command.
The CLI starts a singleton detached learning worker. It waits while the CLI is
active, then continues dispatching up to four features every 30 seconds after
the terminal exits. Web and Gateway processes use the durable `learning_cycle`
scheduler job. Feature switches from `/self-learning` are persisted in SQLite,
so disabled features stay disabled across restarts. Chat turns also dispatch
the input-dependent preference and technology features. Inspect the latest
status with `GET /api/v2/learning/features`. Dynamic tools remain response-time
operations protected by explicit capability and approval gates, and rollback
remains user-directed via `/forget` or an explicit learning rollback command.

### Installed skill guidance

Expand Down Expand Up @@ -954,6 +955,7 @@ OpenKyrozen/
├── providers.py # Multi-LLM abstraction (5 providers + fallback)
├── memory.py # SQLite memory with optional rebuildable Chroma index
├── server.py # FastAPI web server + REST API + chat UI
├── learning_worker.py # Detached durable self-learning worker
├── agent_config.py # Strict agent.yaml loader and capability bound
├── agent.yaml # Validated role/provider/capability configuration
├── pyproject.toml # pip package configuration
Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,7 @@ kyrozen

### 工作原理

CLI 在空闲时每 30 秒通过共享 dispatcher 以 round-robin 方式最多执行 4 个功能Web/Gateway 通过持久化的 `learning_cycle` 调度任务运行;每次聊天 turn 还会运行依赖输入的偏好和技术检测。所有功能都可用 `/self-learning` 独立开关,`GET /api/v2/learning/features` 可查看最新状态。项目扫描采用增量方式,后台任务有并发限制,失败会记录为事件而不会静默丢弃;缺少输入或证据时会诚实记录为无变化的有界 no-op。
CLI 会为每个工作区启动一个独立的单例学习 worker。交互式 CLI 活跃时 worker 会等待;终端退出后,心跳静默至少 60 秒,worker 仍会每 30 秒通过共享 dispatcher 以 round-robin 方式最多执行 4 个功能Web/Gateway 通过持久化的 `learning_cycle` 调度任务运行;每次聊天 turn 还会运行依赖输入的偏好和技术检测。所有功能都可用 `/self-learning` 独立开关,开关会持久化到 SQLite,`GET /api/v2/learning/features` 可查看最新状态。项目扫描采用增量方式,后台任务有并发限制,失败会记录为事件而不会静默丢弃;缺少输入或证据时会诚实记录为无变化的有界 no-op。

| # | 功能 | 学习内容 |
|---|------|---------|
Expand Down
10 changes: 6 additions & 4 deletions docs/self-evolution.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Historical verification snapshot: `51be33361422e55e1f2f00c33a0e0f8c56132a91`
(the post-#54 `main` revision, captured before this #55 documentation-only
update). Snapshot date: 2026-09-04.

Current repository test count at this snapshot: **198 unittest cases**.
Current repository test count at this snapshot: **201 unittest cases**.

## Verified surface

Expand Down Expand Up @@ -62,9 +62,11 @@ API below.
explicit corrections, and verified failures may enter review. Provider
failures, secret-bearing runs, routine one-step work, and project indexing
are excluded from behavioral evolution.
2. The background loop reviews after at least 60 seconds without user
interaction. A reviewer can create at most one bounded `policy` or `skill`
canary for the run, or abstain.
2. The CLI launches one detached learning worker per workspace. The worker
waits until the interactive heartbeat is quiet for at least 60 seconds,
then reviews on its 30-second cycle even after the terminal exits. A
reviewer can create at most one bounded `policy` or `skill` canary for the
run, or abstain. Feature switches are persisted in SQLite.
3. Static validation requires the manifest, Markdown sections, size limits,
secret redaction, workspace containment, and declared permissions. Learned
artifacts cannot add capabilities or dynamic tools.
Expand Down
33 changes: 33 additions & 0 deletions event_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,16 @@ def _initialise_once(self) -> None:
);
CREATE INDEX IF NOT EXISTS idx_learning_status
ON learning_proposals(status, workspace_id, updated_at);
CREATE TABLE IF NOT EXISTS learning_feature_flags (
name TEXT NOT NULL,
user_id TEXT NOT NULL DEFAULT 'local',
workspace_id TEXT NOT NULL DEFAULT 'default',
enabled INTEGER NOT NULL DEFAULT 1,
updated_at TEXT NOT NULL,
PRIMARY KEY(name, user_id, workspace_id)
);
CREATE INDEX IF NOT EXISTS idx_learning_feature_flags_scope
ON learning_feature_flags(user_id, workspace_id, name);
CREATE TABLE IF NOT EXISTS scheduled_jobs (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
Expand Down Expand Up @@ -694,6 +704,29 @@ def add_proposal_evidence(self, proposal_id: str, evidence_id: str) -> int:
(self._json(evidence), utc_now(), proposal_id))
return len(evidence)

def list_learning_feature_flags(self, *, user_id: str = "local",
workspace_id: str = "default") -> dict[str, bool]:
"""Return persisted self-learning feature switches for one scope."""
with self.connection() as db:
rows = db.execute(
"SELECT name, enabled FROM learning_feature_flags "
"WHERE user_id=? AND workspace_id=?",
(user_id, workspace_id),
).fetchall()
return {str(row["name"]): bool(row["enabled"]) for row in rows}

def set_learning_feature_flag(self, name: str, enabled: bool, *,
user_id: str = "local",
workspace_id: str = "default") -> None:
"""Persist one self-learning feature switch transactionally."""
with self._lock, self.connection() as db:
db.execute(
"INSERT INTO learning_feature_flags(name,user_id,workspace_id,enabled,updated_at) "
"VALUES(?,?,?,?,?) ON CONFLICT(name,user_id,workspace_id) DO UPDATE SET "
"enabled=excluded.enabled,updated_at=excluded.updated_at",
(str(name), user_id, workspace_id, int(bool(enabled)), utc_now()),
)

def list_proposals(self, *, status: str | None = None, workspace_id: str = "default", limit: int = 100,
user_id: str | None = None) -> list[dict[str, Any]]:
params: list[Any] = [workspace_id]
Expand Down
199 changes: 199 additions & 0 deletions learning_worker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
"""Detached, singleton worker for durable self-learning cycles."""

from __future__ import annotations

import os
import signal
import subprocess
import sys
import time
from pathlib import Path
from threading import Event

from workspace_context import source_scope_id


WORKER_INTERVAL_SECONDS = 30.0
ACTIVE_CLI_GRACE_SECONDS = 60.0


def _state_root() -> Path:
path = Path.home() / ".kyrozen" / "v2"
path.mkdir(parents=True, exist_ok=True)
return path


def worker_paths(workspace_root: str | os.PathLike[str]) -> tuple[Path, Path]:
"""Return the singleton lock and CLI heartbeat paths for a workspace."""
scope = source_scope_id(workspace_root)
root = _state_root()
return root / f"learning-worker-{scope}.pid", root / f"learning-worker-{scope}.heartbeat"


def touch_cli_heartbeat(workspace_root: str | os.PathLike[str]) -> Path:
"""Record that an interactive CLI is still active for this workspace."""
_, heartbeat = worker_paths(workspace_root)
heartbeat.write_text(str(time.time()), encoding="utf-8")
return heartbeat


def _pid_is_alive(pid: int) -> bool:
if pid <= 0:
return False
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
except OSError:
return False
return True


def worker_is_running(workspace_root: str | os.PathLike[str]) -> bool:
"""Check and clean a stale worker pid file."""
lock, _ = worker_paths(workspace_root)
try:
pid = int(lock.read_text(encoding="utf-8").strip())
except (FileNotFoundError, ValueError, OSError):
if lock.exists():
lock.unlink(missing_ok=True)
return False
if _pid_is_alive(pid):
return True
lock.unlink(missing_ok=True)
return False


def start_worker(*, workspace_root: str | os.PathLike[str], launch_mode: str) -> bool:
"""Start one detached worker, returning False only when spawning fails."""
workspace_root = str(Path(workspace_root).expanduser().resolve())
touch_cli_heartbeat(workspace_root)
if worker_is_running(workspace_root):
return True

env = os.environ.copy()
env.update({
"KYROZEN_EXECUTION_SURFACE": "worker",
"KYROZEN_WORKSPACE_ROOT": workspace_root,
"KYROZEN_LAUNCH_MODE": launch_mode,
"KYROZEN_LEARNING_WORKER": "1",
})
module_root = str(Path(__file__).resolve().parent)
env["PYTHONPATH"] = module_root + os.pathsep + env.get("PYTHONPATH", "")
kwargs: dict[str, object] = {
"stdin": subprocess.DEVNULL,
"stdout": subprocess.DEVNULL,
"stderr": subprocess.DEVNULL,
"close_fds": True,
"env": env,
}
if os.name == "nt":
kwargs["creationflags"] = (
getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
| getattr(subprocess, "DETACHED_PROCESS", 0)
)
else:
kwargs["start_new_session"] = True
try:
subprocess.Popen([sys.executable, "-m", "learning_worker"], **kwargs)
except OSError:
return False
return True


def _claim_worker_lock(lock: Path) -> bool:
try:
fd = os.open(str(lock), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
except FileExistsError:
if worker_is_running_from_lock(lock):
return False
try:
fd = os.open(str(lock), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
except (FileExistsError, OSError):
return False
except OSError:
return False
try:
os.write(fd, str(os.getpid()).encode("ascii"))
finally:
os.close(fd)
return True


def worker_is_running_from_lock(lock: Path) -> bool:
try:
pid = int(lock.read_text(encoding="utf-8").strip())
except (FileNotFoundError, ValueError, OSError):
lock.unlink(missing_ok=True)
return False
if _pid_is_alive(pid):
return True
lock.unlink(missing_ok=True)
return False


def _parent_cli_is_active(heartbeat: Path) -> bool:
try:
return (time.time() - heartbeat.stat().st_mtime) < ACTIVE_CLI_GRACE_SECONDS
except FileNotFoundError:
return False


def worker_main() -> int:
workspace_root = os.environ.get("KYROZEN_WORKSPACE_ROOT", "") or str(
Path.home() / ".kyrozen" / "workspace"
)
lock, heartbeat = worker_paths(workspace_root)
if not _claim_worker_lock(lock):
return 0

stop = Event()

def request_stop(_signum: int, _frame: object) -> None:
stop.set()

for signum in (getattr(signal, "SIGINT", None), getattr(signal, "SIGTERM", None)):
if signum is not None:
try:
signal.signal(signum, request_stop)
except (OSError, RuntimeError):
pass

try:
import main as agent

agent.configure_launch_context()
agent._prompt_and_init_deepseek(interactive=False)
agent._record_learning_event("learning.worker_started", {
"workspace_root": str(Path(workspace_root).resolve()),
"pid": os.getpid(),
})
while not stop.wait(WORKER_INTERVAL_SECONDS):
if _parent_cli_is_active(heartbeat):
continue
agent._restore_self_learning_flags()
if not any(agent._SELF_LEARNING_FLAGS.values()):
continue
try:
agent.dispatch_learning_cycle(
surface="worker", trigger="detached", max_features=4,
)
except Exception as exc:
agent.memory_bank.store.append_event(
"learning.worker_cycle_failed", {"error": str(exc)[:1000]},
user_id=agent.memory_bank.user_id,
workspace_id=agent.memory_bank.workspace_id,
session_id=agent.memory_bank.session_id,
)
finally:
try:
lock.unlink(missing_ok=True)
except OSError:
pass
return 0


if __name__ == "__main__":
raise SystemExit(worker_main())
Loading
Loading