feat(lib): implement A-4 M0~M1 BaseAgentAdapter pattern and resolve_home contract
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# lib_py.agents package initialization — BaseAgentAdapter and static registry
|
||||
@@ -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'
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user