feat(lib): implement A-4 M0~M1 BaseAgentAdapter pattern and resolve_home contract

This commit is contained in:
2026-08-14 08:44:36 +09:00
parent e10db8966e
commit caffd04f36
20 changed files with 743 additions and 15 deletions
+2 -1
View File
@@ -1120,7 +1120,8 @@ PYEOF
if [ -f "$SKILL_DIR/lib_py/verify_session.py" ]; then
VERIFY_SESSION_PYTHON="$(cat "$SKILL_DIR/lib_py/verify_session.py")"
else
VERIFY_SESSION_PYTHON=""
echo "ERROR: lib_py/verify_session.py missing" >&2
return 1 2>/dev/null || exit 1
fi
verify_session_uuid() {
+1
View File
@@ -0,0 +1 @@
# lib_py.agents package initialization — BaseAgentAdapter and static registry
+35
View File
@@ -0,0 +1,35 @@
# __main__.py — CLI bridge for lib_py.agents facts and resolution (N4)
import sys, json
from lib_py.agents.registry import get_adapter, own_key, agent_of_row
def main():
if len(sys.argv) < 2:
print("Usage: python -m lib_py.agents <facts|resolve> [args...]", file=sys.stderr)
sys.exit(1)
cmd = sys.argv[1]
if cmd == 'facts':
agent_name = sys.argv[2] if len(sys.argv) > 2 else ''
adapter = get_adapter(agent_name)
if not adapter:
print(f"ERROR: Unknown agent {agent_name!r}", file=sys.stderr)
sys.exit(1)
# Emit shell-eval friendly facts
print(f"AGENT_NAME={adapter.name}")
print(f"OWN_KEY={adapter.own_key}")
elif cmd == 'resolve':
name = sys.argv[2] if len(sys.argv) > 2 else ''
resolved = agent_of_row({}, session_name=name)
if resolved:
print(resolved)
sys.exit(0)
sys.exit(1)
else:
print(f"ERROR: Unknown CLI command {cmd!r}", file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
main()
@@ -0,0 +1 @@
# Adapters package init
@@ -0,0 +1,12 @@
# agy.py — Antigravity (agy) agent adapter
from lib_py.agents.base import BaseAgentAdapter
class AgyAgentAdapter(BaseAgentAdapter):
@property
def name(self) -> str:
return 'agy'
@property
def own_key(self) -> str:
return 'agy_conversation_id_own'
@@ -0,0 +1,12 @@
# claude.py — Claude Code agent adapter
from lib_py.agents.base import BaseAgentAdapter
class ClaudeAgentAdapter(BaseAgentAdapter):
@property
def name(self) -> str:
return 'claude'
@property
def own_key(self) -> str:
return 'claude_session_id_own'
@@ -0,0 +1,12 @@
# cline.py — Cline agent adapter
from lib_py.agents.base import BaseAgentAdapter
class ClineAgentAdapter(BaseAgentAdapter):
@property
def name(self) -> str:
return 'cline'
@property
def own_key(self) -> str:
return 'cline_conversation_id_own'
@@ -0,0 +1,12 @@
# hermes.py — Hermes agent adapter
from lib_py.agents.base import BaseAgentAdapter
class HermesAgentAdapter(BaseAgentAdapter):
@property
def name(self) -> str:
return 'hermes'
@property
def own_key(self) -> str:
return 'hermes_conversation_id_own'
+41
View File
@@ -0,0 +1,41 @@
# base.py — BaseAgentAdapter abstract base class, SpawnSpec, and DiscoveryContext
import os, json, sqlite3
from typing import Optional, Dict, Any, List
from lib_py.paths import resolve_home
from lib_py.verify_session import verify_session_uuid, workspace_key
class SpawnSpec:
def __init__(self, agent_name: str, session_name: str, command: str, env: Optional[Dict[str, str]] = None):
self.agent_name = agent_name
self.session_name = session_name
self.command = command
self.env = env or {}
class DiscoveryContext:
def __init__(self, workspace: str, agent_name: str, home_dir: Optional[str] = None):
self.workspace = workspace
self.agent_name = agent_name
self.home_dir = resolve_home(home_dir)
class BaseAgentAdapter:
@property
def name(self) -> str:
raise NotImplementedError
@property
def own_key(self) -> str:
raise NotImplementedError
def derive_session_name(self, slug: str, role: str = "creator") -> str:
r = role.lower()
return f"{slug}-{r}-{self.name}"
def matches_session_name(self, session_name: str) -> bool:
for r in ('creator', 'planner', 'reviewer'):
if session_name.endswith(f"-{r}-{self.name}"):
return True
return session_name.endswith(f"-{self.name}")
def verify_session(self, ws: str, uuid: str, row: Optional[Dict[str, Any]] = None, mode: str = "discover") -> bool:
return verify_session_uuid(ws, self.name, uuid, row=row, mode=mode)
+55
View File
@@ -0,0 +1,55 @@
# registry.py — Static agent adapter registry and resolution functions (N4 & N5)
from typing import Optional, Dict, Any
from lib_py.agents.base import BaseAgentAdapter
from lib_py.agents.adapters.claude import ClaudeAgentAdapter
from lib_py.agents.adapters.agy import AgyAgentAdapter
from lib_py.agents.adapters.hermes import HermesAgentAdapter
from lib_py.agents.adapters.cline import ClineAgentAdapter
_ADAPTERS: Dict[str, BaseAgentAdapter] = {
'claude': ClaudeAgentAdapter(),
'agy': AgyAgentAdapter(),
'hermes': HermesAgentAdapter(),
'cline': ClineAgentAdapter(),
}
def get_adapter(agent_name: str) -> Optional[BaseAgentAdapter]:
if not agent_name:
return None
return _ADAPTERS.get(agent_name.lower())
def own_key(agent_name: str) -> Optional[str]:
adapter = get_adapter(agent_name)
return adapter.own_key if adapter else None
def agent_of_row(row: Dict[str, Any], session_name: str = "", match_cmd: bool = True) -> Optional[str]:
"""
Resolves agent for a given registry row or session name using strict priority order (N5):
1. Explicit 'agent' field in row dictionary
2. Session name suffix matching (*-{creator,planner,reviewer}-<agent> or *-<agent>)
3. pane.cmd exact match (if match_cmd is True, e.g. for non-adoption lookup)
4. None (resolution failure)
"""
if isinstance(row, dict):
explicit = row.get('agent')
if explicit and str(explicit).lower() in _ADAPTERS:
return str(explicit).lower()
if not session_name:
session_name = row.get('name', '')
name = session_name or (row.get('name', '') if isinstance(row, dict) else '')
if name:
for agent_name, adapter in _ADAPTERS.items():
if adapter.matches_session_name(name):
return agent_name
if match_cmd and isinstance(row, dict):
pane = row.get('pane') or {}
cmd = pane.get('cmd', '') if isinstance(pane, dict) else ''
if cmd:
for agent_name in _ADAPTERS:
if cmd == agent_name or cmd.endswith(f"/{agent_name}"):
return agent_name
return None
+14
View File
@@ -0,0 +1,14 @@
# paths.py — unified HOME_DIR and filesystem path resolution contract (N0)
import os
def resolve_home(home_dir=None):
"""
Unified HOME_DIR resolution contract across lib_py modules and adapters.
Tries: 1. explicit home_dir argument, 2. HOME_DIR env var, 3. HOME env var, 4. os.path.expanduser('~').
Raises ValueError if result is empty or filesystem root ('/'), preventing root-relative silent fail-close.
"""
h = home_dir or os.environ.get("HOME_DIR") or os.environ.get("HOME") or os.path.expanduser("~")
if not h or h == "/":
raise ValueError("HOME_DIR unresolvable — refusing to build paths from the filesystem root")
return h
+49
View File
@@ -0,0 +1,49 @@
# state.py — Python direct state loader for agent-sessions.yaml / agent-sessions.db (N7)
import os, json, sqlite3
from typing import Dict, Any
def load_state_json(yaml_path: str = None) -> Dict[str, Any]:
if not yaml_path:
yaml_path = os.environ.get('AGENT_SESSIONS_YAML') or os.environ.get('YAML_PATH', '')
if not yaml_path:
return {}
db_path = os.path.splitext(yaml_path)[0] + '.db'
d = {}
db_sessions = []
try:
if os.path.exists(db_path):
conn = sqlite3.connect(db_path, timeout=60.0)
conn.execute('PRAGMA busy_timeout = 60000')
try:
row = conn.execute('SELECT data FROM state WHERE id=1').fetchone()
if row:
d = json.loads(row[0])
except sqlite3.OperationalError:
pass
try:
cursor = conn.execute('SELECT data FROM sessions')
for r in cursor.fetchall():
db_sessions.append(json.loads(r[0]))
d['herdr_sessions'] = db_sessions
except sqlite3.OperationalError:
pass
conn.close()
elif os.path.exists(yaml_path):
import yaml
with open(yaml_path) as f:
d = yaml.safe_load(f) or {}
except Exception:
pass
def clean_surrogates(obj):
if isinstance(obj, str):
return obj.encode('utf-8', errors='replace').decode('utf-8')
elif isinstance(obj, dict):
return {k: clean_surrogates(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [clean_surrogates(x) for x in obj]
return obj
return clean_surrogates(d)
+3 -1
View File
@@ -79,9 +79,11 @@ def workspace_key(path):
p = path
return p.replace("/", "-").replace("_", "-")
from lib_py.paths import resolve_home
def verify_session_uuid(ws, agent, uuid, row=None, home_dir=None, claude_dir=None, mode="discover"):
import os, json, sqlite3
home = home_dir or os.environ.get("HOME_DIR", "")
home = resolve_home(home_dir)
c_dir = claude_dir or os.environ.get("CLAUDE_PROJECT_DIR", f"{home}/.claude/projects")
row = row or {}
_iso = row.get("isolation")
+3 -1
View File
@@ -15,10 +15,12 @@ def iso_root_of(s):
iso = s.get('isolation')
return iso.get('root') if isinstance(iso, dict) else None
from lib_py.paths import resolve_home
def find_workspace_uuid_main():
ws = os.environ['WS_ABS']
agent = os.environ['AGENT']
home = os.environ['HOME_DIR']
home = resolve_home()
claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects")
target = os.environ.get('TARGET_SESSION', '')
@@ -320,7 +320,7 @@ import os, json, glob, subprocess, time, sqlite3
from datetime import datetime, timezone
import yaml
exec(os.environ['MAM_VERIFY_PY'])
from lib_py.verify_session import verify_session_uuid, workspace_key
yaml_path = os.environ['YAML_PATH']
home = os.environ['HOME_DIR']
@@ -344,14 +344,18 @@ if not lib_sh:
try:
d
except NameError:
import subprocess
d = {}
try:
script = f"source '{lib_sh}' && load_state_json"
out = subprocess.check_output(['bash', '-c', script], stderr=subprocess.DEVNULL)
d = json.loads(out.decode('utf-8'))
from lib_py.state import load_state_json
d = load_state_json(yaml_path)
except Exception:
pass
import subprocess
d = {}
try:
script = f"source '{lib_sh}' && load_state_json"
out = subprocess.check_output(['bash', '-c', script], stderr=subprocess.DEVNULL)
d = json.loads(out.decode('utf-8'))
except Exception:
pass
# Any herdr_session_epoch at or below this is not a real session time.
# 1000000000 = 2001-09-09; it is below every plausible MAM session and far
@@ -859,7 +863,7 @@ if not actions:
PYEOF
if [ "$DRY_RUN" = "1" ]; then
printf '%s' "$RECON_SRC" | LIB_SH="$LIB_SH" MAM_VERIFY_PY="$VERIFY_SESSION_PYTHON" env_python "$AGENT_SESSIONS_YAML"
printf '%s' "$RECON_SRC" | LIB_SH="$LIB_SH" env_python "$AGENT_SESSIONS_YAML"
else
printf '%s' "$RECON_SRC" | LIB_SH="$LIB_SH" MAM_VERIFY_PY="$VERIFY_SESSION_PYTHON" atomic_dump_yaml "$AGENT_SESSIONS_YAML"
printf '%s' "$RECON_SRC" | LIB_SH="$LIB_SH" atomic_dump_yaml "$AGENT_SESSIONS_YAML"
fi