From 365f962d480a75682e5a9af6fde554cfe3da958d Mon Sep 17 00:00:00 2001 From: aarontuor Date: Fri, 12 Jun 2026 10:44:36 -0700 Subject: [PATCH 01/44] feat(skills): external skill catalogs, native discovery, skill-creator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bridge dsagt's skill system into agent-native discovery and add a searchable catalog of installable skills from external GitHub repos. Two tiers: - Catalog — external Agent-Skills repos (default: K-Dense scientific, 140+) cloned + indexed into per-source `skills_catalog__` KB collections. Searchable via search_skills, never loaded into the agent's context. - Installed — a chosen skill copied into /skills/ and mirrored into .claude/skills/ for Claude Code's native discovery. Changes: - commands/skills_catalog.py: shallow-clone cache, recursive SKILL.md discovery, per-source indexing (idempotent re-sync via drop+rebuild), find/install. Known sources: scientific, anthropic, antigravity, composio. - MCP tools: install_skill + catalog-spanning search_skills (registry); add_skill_source / list_skill_sources (knowledge). Added to auto-allow. - agents/base._mirror_skills_to + ClaudeSetup hook: manifest-gated mirror into .claude/skills/ (never clobbers user skills; reaps stale entries; trims >1536-char descriptions in the copy only). - Bundled skill-creator meta-skill (Anthropic template + condensed spec). - CLI: dsagt skills sync/add/list/search. - Config: skills block (sources/populate_native/populate_catalog), backfilled for old configs; setup-kb syncs the default catalog (--no-skill-catalog to skip); reserve .skill_sources; kb_from_config. - dsagt_instructions.md: two-tier guidance (native vs catalog/install). - use_cases/isaac_skills_demo: runnable mock of the isaac_vasp workflow exercising the full flow with tiny mock VASP data. Tests: test_skills_catalog.py + config/server-routing additions (201 passed, 13 skipped); black + ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/dsagt/agents/base.py | 81 +++++ src/dsagt/agents/claude.py | 13 + src/dsagt/commands/cli.py | 158 ++++++++++ src/dsagt/commands/knowledge_server.py | 288 ++++++++++++++--- src/dsagt/commands/registry_server.py | 298 ++++++++++++++---- src/dsagt/commands/setup_core_kb.py | 128 ++++++-- src/dsagt/commands/skills_catalog.py | 277 ++++++++++++++++ src/dsagt/dsagt_instructions.md | 2 + src/dsagt/registry.py | 51 ++- src/dsagt/session.py | 190 ++++++++--- src/dsagt/skills/skill-creator/SKILL.md | 65 ++++ .../references/SKILL_template.md | 53 ++++ .../references/agent_skills_spec.md | 48 +++ tests/test_config.py | 26 ++ tests/test_knowledge_server.py | 298 +++++++++++++----- tests/test_registry_server.py | 239 ++++++++++---- tests/test_skills_catalog.py | 196 ++++++++++++ use_cases/isaac_skills_demo/README.md | 159 ++++++++++ .../mock_data/expected_isaac_record.json | 63 ++++ .../mock_data/mock_slab/INCAR | 18 ++ .../mock_data/mock_slab/OUTCAR | 42 +++ .../mock_data/mock_slab/POSCAR | 21 ++ 22 files changed, 2379 insertions(+), 335 deletions(-) create mode 100644 src/dsagt/commands/skills_catalog.py create mode 100644 src/dsagt/skills/skill-creator/SKILL.md create mode 100644 src/dsagt/skills/skill-creator/references/SKILL_template.md create mode 100644 src/dsagt/skills/skill-creator/references/agent_skills_spec.md create mode 100644 tests/test_skills_catalog.py create mode 100644 use_cases/isaac_skills_demo/README.md create mode 100644 use_cases/isaac_skills_demo/mock_data/expected_isaac_record.json create mode 100644 use_cases/isaac_skills_demo/mock_data/mock_slab/INCAR create mode 100644 use_cases/isaac_skills_demo/mock_data/mock_slab/OUTCAR create mode 100644 use_cases/isaac_skills_demo/mock_data/mock_slab/POSCAR diff --git a/src/dsagt/agents/base.py b/src/dsagt/agents/base.py index 2835d80..454ac46 100644 --- a/src/dsagt/agents/base.py +++ b/src/dsagt/agents/base.py @@ -13,6 +13,7 @@ import json import logging import shlex +import shutil import subprocess from abc import ABC, abstractmethod from pathlib import Path @@ -41,6 +42,7 @@ "get_registry", "http_request", "install_dependencies", + "install_skill", "read_file", "reconstruct_pipeline", "run_command", @@ -50,6 +52,7 @@ "search_skills", ], "knowledge": [ + "add_skill_source", "kb_add_vector_db", "kb_append", "kb_dismiss_suggestion", @@ -60,6 +63,7 @@ "kb_list_collections", "kb_remember", "kb_search", + "list_skill_sources", ], } @@ -212,6 +216,83 @@ def _append_or_write(path: Path, content: str, marker: str) -> str | None: return f"Wrote {path}" +#: Claude Code caps a skill's frontmatter description (combined with +#: when_to_use) at this many characters; longer ones are rejected. We +#: truncate the *mirrored* copy only, never the project source. +_NATIVE_DESCRIPTION_CAP = 1536 + +#: Manifest filename inside a native skills dir listing the skill names +#: dsagt placed there, so the mirror can reap its own stale entries on +#: re-run without ever touching user-authored skills. +_SKILL_MANIFEST = ".dsagt-managed.json" + + +def _truncate_native_description(skill_md: Path) -> None: + """If the mirrored SKILL.md's description exceeds the native cap, trim it.""" + import yaml + + text = skill_md.read_text() + if not text.startswith("---"): + return + parts = text.split("---", 2) + if len(parts) < 3: + return + try: + front = yaml.safe_load(parts[1]) or {} + except yaml.YAMLError: + return + desc = front.get("description") + if isinstance(desc, str) and len(desc) > _NATIVE_DESCRIPTION_CAP: + front["description"] = desc[: _NATIVE_DESCRIPTION_CAP - 1].rstrip() + "…" + new_front = yaml.dump(front, default_flow_style=False, sort_keys=False) + skill_md.write_text(f"---\n{new_front}---{parts[2]}") + + +def _mirror_skills_to(target_dir: Path, skill_dirs: list[Path]) -> list[str]: + """Idempotently mirror *skill_dirs* into *target_dir* (e.g. .claude/skills). + + Copies each skill directory (SKILL.md + scripts/ + references/) under + ``target_dir//``. A manifest tracks the names dsagt owns so a + later run reaps skills that were removed upstream **without ever + touching user-authored skills** that dsagt didn't place. ``skill_dirs`` + should list bundled dirs before project dirs so a project skill wins a + name collision (copied last). + """ + actions: list[str] = [] + manifest_path = target_dir / _SKILL_MANIFEST + previously: list[str] = [] + if manifest_path.exists(): + try: + previously = json.loads(manifest_path.read_text()) + except (json.JSONDecodeError, OSError): + previously = [] + + target_dir.mkdir(parents=True, exist_ok=True) + managed: list[str] = [] + for src in skill_dirs: + if not (src / "SKILL.md").exists(): + continue + name = src.name + dest = target_dir / name + if dest.exists(): + shutil.rmtree(dest) + shutil.copytree(src, dest) + _truncate_native_description(dest / "SKILL.md") + if name not in managed: + managed.append(name) + + # Reap skills dsagt placed previously that are gone from the source set. + for stale in set(previously) - set(managed): + stale_dir = target_dir / stale + if stale_dir.is_dir(): + shutil.rmtree(stale_dir, ignore_errors=True) + + manifest_path.write_text(json.dumps(sorted(managed), indent=2) + "\n") + if managed: + actions.append(f"Mirrored {len(managed)} skill(s) into {target_dir}") + return actions + + def _build_mcp_servers_dict(env_block: dict | None) -> dict: """Build the standard ``{"mcpServers": {...}}`` dict for dsagt servers. diff --git a/src/dsagt/agents/claude.py b/src/dsagt/agents/claude.py index f2a4094..87ad480 100644 --- a/src/dsagt/agents/claude.py +++ b/src/dsagt/agents/claude.py @@ -52,6 +52,7 @@ _load_master_instructions, _mcp_env_block, _mcp_server_args, + _mirror_skills_to, _run_simple_script, ) @@ -168,6 +169,18 @@ def write_dynamic( mcp_path.write_text(json.dumps(mcp_config, indent=2) + "\n") actions.append(f"Wrote {mcp_path}") + # Mirror installed (project) + bundled skills into Claude Code's + # native skill dir so it discovers/auto-invokes them without an MCP + # round-trip. Bundled first, project last → project wins collisions. + # A newly-created .claude/skills/ is only picked up on Claude restart, + # which is fine: this runs at init/start, before the agent launches. + if (config.get("skills") or {}).get("populate_native", True): + from dsagt.registry import SkillRegistry + + reg = SkillRegistry(runtime_dir=working_dir, kb=None) + src_dirs = reg._bundled_skill_dirs() + reg._project_skill_dirs() + actions += _mirror_skills_to(working_dir / ".claude" / "skills", src_dirs) + # Configure mlflow autolog claude — writes .claude/settings.json # with the MLflow Stop hook + tracking env vars. Idempotent and # preserves any existing keys in settings.json (mlflow's setup diff --git a/src/dsagt/commands/cli.py b/src/dsagt/commands/cli.py index 40d30d4..f935846 100644 --- a/src/dsagt/commands/cli.py +++ b/src/dsagt/commands/cli.py @@ -382,6 +382,129 @@ def _cmd_setup_kb(args): run_setup_kb(args) +def _cmd_skills(args): + """Manage external skill catalogs and project skill installs.""" + from dsagt.commands.skills_catalog import ( + KNOWN_SOURCES, + install_into_project, + sync_source, + ) + from dsagt.registry import ( + CATALOG_COLLECTION_PREFIX, + SKILLS_COLLECTION, + SkillRegistry, + ) + from dsagt.session import kb_from_config, load_config + + action = getattr(args, "skills_action", None) + if not action: + print( + "Usage: dsagt skills ...", file=sys.stderr + ) + return 1 + + config = load_config(args.project) + pdir = Path(config["project_dir"]) + + if action == "sync": + kb = kb_from_config(config) + try: + sources = ( + [args.source] + if args.source + else config.get("skills", {}).get("sources", []) + ) + if not sources: + print("No skill sources configured.") + return 0 + for src in sources: + stats = sync_source(src, kb=kb, force=args.force) + print( + f" {stats['url']}: {stats['indexed']} skill(s) indexed (slug {stats['slug']})" + ) + finally: + kb.close() + return 0 + + if action == "add": + target = args.target + is_source = ( + target in KNOWN_SOURCES + or target.startswith(("http://", "https://", "git@")) + or target.count("/") == 1 + ) + if is_source: + kb = kb_from_config(config) + try: + stats = sync_source(target, kb=kb) + finally: + kb.close() + print(f"Added source {stats['url']}: {stats['indexed']} skill(s) indexed.") + print( + "Run 'dsagt start' to mirror an installed skill natively, or " + f"'dsagt skills add {args.project} ' to install one." + ) + else: + info = install_into_project(target, pdir) + print( + f"{info['action'].capitalize()} skill '{info['name']}' at {info['dest_dir']}." + ) + print("It becomes natively discoverable on the next 'dsagt start'.") + return 0 + + if action == "list": + if args.catalog: + kb = kb_from_config(config) + try: + cats = [ + c for c in kb.collections if c.startswith(CATALOG_COLLECTION_PREFIX) + ] + finally: + kb.close() + print( + "Catalog collections:" + if cats + else "No catalog synced. Run 'dsagt skills sync'." + ) + for c in sorted(cats): + print(f" {c}") + else: + reg = SkillRegistry(runtime_dir=pdir, kb=None) + skills = reg.list_skills() + print(f"Installed/bundled skills ({len(skills)}):") + for s in skills: + print(f" {s.get('name')} — {(s.get('description') or '')[:80]}") + return 0 + + if action == "search": + kb = kb_from_config(config) + try: + collections = [SKILLS_COLLECTION] + [ + c for c in kb.collections if c.startswith(CATALOG_COLLECTION_PREFIX) + ] + hits = [] + for coll in collections: + try: + hits.extend(kb.search(query=args.query, collection=coll, top_k=10)) + except (FileNotFoundError, KeyError, ValueError): + continue + hits.sort(key=lambda r: r.get("score", 0), reverse=True) + for r in hits[:10]: + meta = r.get("chunk", {}).get("metadata", {}) + print( + f" {meta.get('skill_name', '?')} ({r.get('score', 0):.2f}) " + f"[{meta.get('source', '')}]" + ) + if not hits: + print("No skills found.") + finally: + kb.close() + return 0 + + print(f"Unknown skills action: {action}", file=sys.stderr) + return 1 + + def _cmd_mlflow(args): """Run MLflow in the foreground. @@ -1032,6 +1155,40 @@ def main(argv=None): add_setup_kb_args(p_setup_kb) + p_skills = sub.add_parser( + "skills", help="Manage external skill catalogs and project installs" + ) + skills_sub = p_skills.add_subparsers(dest="skills_action") + sp_sync = skills_sub.add_parser( + "sync", help="Clone + index skill source(s) into the catalog" + ) + sp_sync.add_argument("project", help="Project name") + sp_sync.add_argument( + "--source", help="Known source name or GitHub URL (default: all configured)" + ) + sp_sync.add_argument( + "--force", action="store_true", help="Re-clone sources from scratch" + ) + sp_add = skills_sub.add_parser( + "add", help="Install a catalog skill, or add+sync a new source" + ) + sp_add.add_argument("project", help="Project name") + sp_add.add_argument( + "target", help="Skill name to install, or source name/URL to add" + ) + sp_list = skills_sub.add_parser( + "list", help="List installed skills (or --catalog collections)" + ) + sp_list.add_argument("project", help="Project name") + sp_list.add_argument( + "--catalog", action="store_true", help="List synced catalog collections" + ) + sp_search = skills_sub.add_parser( + "search", help="Search installed + catalog skills" + ) + sp_search.add_argument("project", help="Project name") + sp_search.add_argument("query", help="Search query") + sub.add_parser("list", help="List all registered projects and their status") p_mv = sub.add_parser("mv", help="Move a project to a new location") @@ -1077,6 +1234,7 @@ def main(argv=None): "stop": _cmd_stop, "smoke-test": _cmd_smoke_test, "setup-kb": _cmd_setup_kb, + "skills": _cmd_skills, "list": _cmd_list, "mv": _cmd_mv, "rm": _cmd_rm, diff --git a/src/dsagt/commands/knowledge_server.py b/src/dsagt/commands/knowledge_server.py index dd970cd..a06716c 100644 --- a/src/dsagt/commands/knowledge_server.py +++ b/src/dsagt/commands/knowledge_server.py @@ -38,10 +38,19 @@ from mcp.server.lowlevel import Server, NotificationOptions from mcp.server.models import InitializationOptions -from dsagt.knowledge import EMBEDDER_REGISTRY, VECTORINDEX_REGISTRY, CollectionRoute, KnowledgeBase +from dsagt.knowledge import ( + EMBEDDER_REGISTRY, + VECTORINDEX_REGISTRY, + CollectionRoute, + KnowledgeBase, +) from dsagt.memory import SuggestionQueue from dsagt.memory import ExplicitMemory -from dsagt.session import REGISTRY_DIR, _collection_exists, setup_runtime_kb # noqa: F401 +from dsagt.session import ( + REGISTRY_DIR, + _collection_exists, + setup_runtime_kb, +) # noqa: F401 logger = logging.getLogger(__name__) @@ -50,6 +59,7 @@ # MCP server helpers # --------------------------------------------------------------------------- + async def _run_stdio(server: Server, name: str) -> None: async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): await server.run( @@ -66,8 +76,6 @@ async def _run_stdio(server: Server, name: str) -> None: ) - - # _collection_exists and setup_runtime_kb live in dsagt.session (imported above). @@ -120,12 +128,11 @@ def _register_external_collection( kb.register_route(collection_name, route) - - # --------------------------------------------------------------------------- # Background job tracker # --------------------------------------------------------------------------- + @dataclass class _JobTracker: """Tracks background ingest/append jobs and their completion state.""" @@ -157,6 +164,7 @@ async def _run(): tracker.jobs[job_id]["message"] = "Done." except Exception as e: import traceback + tb = traceback.format_exc() tracker.jobs[job_id]["status"] = "error" tracker.jobs[job_id]["error"] = f"{type(e).__name__}: {e}" @@ -179,13 +187,16 @@ async def _run(): # return a result dict; the outer call_tool wrapper JSON-serializes it. # --------------------------------------------------------------------------- + async def _handle_kb_list_collections(arguments: dict, *, kb: KnowledgeBase) -> dict: collections = await asyncio.to_thread(kb.list_collections) return {"status": "ok", "collections": collections, "count": len(collections)} async def _handle_kb_search( - arguments: dict, *, kb: KnowledgeBase, + arguments: dict, + *, + kb: KnowledgeBase, ) -> dict: query = arguments["query"] top_k = arguments.get("top_k", 5) @@ -217,7 +228,9 @@ async def _handle_kb_search( for coll_name in target_collections: try: - search_kwargs = dict(query=query, collection=coll_name, top_k=top_k, rerank=rerank) + search_kwargs = dict( + query=query, collection=coll_name, top_k=top_k, rerank=rerank + ) if where: search_kwargs["where"] = where coll_results = await asyncio.to_thread(kb.search, **search_kwargs) @@ -229,7 +242,10 @@ async def _handle_kb_search( if search_errors and not all_results: if len(target_collections) == 1: return {"status": "error", "error": search_errors[0]} - return {"status": "error", "error": f"All collections failed: {'; '.join(search_errors)}"} + return { + "status": "error", + "error": f"All collections failed: {'; '.join(search_errors)}", + } score_key = "rerank_score" if rerank else "score" all_results.sort(key=lambda r: r.get(score_key, r["score"]), reverse=True) @@ -248,8 +264,10 @@ async def _handle_kb_search( "source_file": r["chunk"]["metadata"].get("source_file", ""), "chunk_index": r["chunk"]["metadata"].get("chunk_index", 0), "metadata": { - k: v for k, v in r["chunk"]["metadata"].items() - if k not in ("source_file", "chunk_index", "collection", "file_type") + k: v + for k, v in r["chunk"]["metadata"].items() + if k + not in ("source_file", "chunk_index", "collection", "file_type") }, } for r in all_results @@ -261,7 +279,10 @@ async def _handle_kb_search( async def _handle_kb_ingest( - arguments: dict, *, kb: KnowledgeBase, job_tracker: _JobTracker, + arguments: dict, + *, + kb: KnowledgeBase, + job_tracker: _JobTracker, ) -> dict: folder_path = Path(arguments["folder_path"]) collection_name = arguments.get("collection_name") @@ -289,7 +310,9 @@ async def _handle_kb_ingest( if _collection_exists(kb.index_dir / target_name): source_path = kb.index_dir / target_name / "source.txt" - existing_source = source_path.read_text().strip() if source_path.exists() else None + existing_source = ( + source_path.read_text().strip() if source_path.exists() else None + ) same_source = ( existing_source is None or Path(existing_source).resolve() == folder_path.resolve() @@ -326,8 +349,13 @@ async def _handle_kb_ingest( async def _ingest_with_logging(): import traceback as _tb - logger.info("Ingest starting: collection=%s folder=%s kwargs=%s", - target_name, folder_path, ingest_kwargs) + + logger.info( + "Ingest starting: collection=%s folder=%s kwargs=%s", + target_name, + folder_path, + ingest_kwargs, + ) try: result = await asyncio.to_thread(kb.ingest, folder_path, **ingest_kwargs) logger.info("Ingest complete: %s", result) @@ -354,7 +382,10 @@ async def _ingest_with_logging(): async def _handle_kb_append( - arguments: dict, *, kb: KnowledgeBase, job_tracker: _JobTracker, + arguments: dict, + *, + kb: KnowledgeBase, + job_tracker: _JobTracker, ) -> dict: collection = arguments["collection"] paths = arguments["paths"] @@ -399,8 +430,12 @@ async def _handle_kb_add_vector_db(arguments: dict, *, kb: KnowledgeBase) -> dic await asyncio.to_thread( _register_external_collection, - kb, collection_name, vector_db, - connection_params, embedding_model, description, + kb, + collection_name, + vector_db, + connection_params, + embedding_model, + description, ) return { "status": "ok", @@ -472,11 +507,13 @@ async def _handle_kb_remember( kb.add_entries, texts=[text], collection="session_memory", - metadatas=[{ - "source_type": "explicit_memory", - "category": category, - "session_id": session_id, - }], + metadatas=[ + { + "source_type": "explicit_memory", + "category": category, + "session_id": session_id, + } + ], ) if promoted_from: @@ -492,7 +529,10 @@ async def _handle_kb_remember( async def _handle_kb_get_memories( - arguments: dict, *, memory: ExplicitMemory, suggestions: SuggestionQueue, + arguments: dict, + *, + memory: ExplicitMemory, + suggestions: SuggestionQueue, ) -> dict: entries = await asyncio.to_thread(memory.get_all) pending = suggestions.get_all() @@ -504,14 +544,18 @@ async def _handle_kb_get_memories( async def _handle_kb_get_suggestions( - arguments: dict, *, suggestions: SuggestionQueue, + arguments: dict, + *, + suggestions: SuggestionQueue, ) -> dict: pending = suggestions.get_all() return {"status": "ok", "count": len(pending), "suggestions": pending} async def _handle_kb_dismiss_suggestion( - arguments: dict, *, suggestions: SuggestionQueue, + arguments: dict, + *, + suggestions: SuggestionQueue, ) -> dict: suggestion_id = arguments["suggestion_id"] dismissed = suggestions.dismiss(suggestion_id) @@ -524,6 +568,74 @@ async def _handle_kb_dismiss_suggestion( # Server factory (thin wiring — used by main() and tests) # --------------------------------------------------------------------------- + +def _persist_skill_source(runtime_dir: Path, spec: dict) -> None: + """Append a resolved source to ``skills.sources`` in the project config. + + Dedupes by URL. No-op if the config file is missing (e.g. tests with a + bare runtime dir) — the catalog is still indexed either way. + """ + cfg_path = runtime_dir / "dsagt_config.yaml" + if not cfg_path.exists(): + return + cfg = yaml.safe_load(cfg_path.read_text()) or {} + skills = cfg.setdefault("skills", {}) + sources = skills.setdefault("sources", []) + if not any(s.get("url") == spec.get("url") for s in sources): + sources.append( + {k: spec[k] for k in ("name", "url", "branch", "subdir") if k in spec} + ) + cfg_path.write_text(yaml.dump(cfg, default_flow_style=False, sort_keys=False)) + + +async def _handle_add_skill_source( + arguments: dict, + *, + kb: KnowledgeBase, + runtime_dir: Path, +) -> dict: + """Enable a skill source (known name or GitHub URL): clone + index the catalog.""" + from dsagt.commands.skills_catalog import KNOWN_SOURCES, resolve_source, sync_source + + source = arguments.get("source") + if not source: + return { + "error": "add_skill_source requires 'source' (known name or GitHub URL)." + } + try: + spec = resolve_source(source) + if isinstance(source, str) and source in KNOWN_SOURCES: + spec.setdefault("name", source) + stats = await asyncio.to_thread(sync_source, source, kb=kb) + except (ValueError, RuntimeError) as e: + return {"error": str(e)} + _persist_skill_source( + runtime_dir, {"name": spec.get("name", stats["slug"]), **spec} + ) + return { + "source": spec["url"], + "slug": stats["slug"], + "skills_indexed": stats["indexed"], + "note": "Searchable via search_skills; install one with install_skill.", + } + + +async def _handle_list_skill_sources(arguments: dict, *, kb: KnowledgeBase) -> dict: + """List known + synced skill sources and their indexed counts.""" + from dsagt.commands.skills_catalog import KNOWN_SOURCES + from dsagt.registry import CATALOG_COLLECTION_PREFIX + + synced = {c for c in kb.collections if c.startswith(CATALOG_COLLECTION_PREFIX)} + return { + "known_sources": { + name: {"url": s["url"], "description": s.get("description", "")} + for name, s in KNOWN_SOURCES.items() + }, + "synced_collections": sorted(synced), + "note": "add_skill_source to enable; search_skills to browse.", + } + + def create_knowledge_server( kb: KnowledgeBase, runtime_dir: str | Path | None = None, @@ -545,21 +657,57 @@ def create_knowledge_server( job_tracker = _JobTracker() handlers = { + "add_skill_source": partial( + _handle_add_skill_source, kb=kb, runtime_dir=mem_dir + ), + "list_skill_sources": partial(_handle_list_skill_sources, kb=kb), "kb_list_collections": partial(_handle_kb_list_collections, kb=kb), "kb_search": partial(_handle_kb_search, kb=kb), "kb_ingest": partial(_handle_kb_ingest, kb=kb, job_tracker=job_tracker), "kb_append": partial(_handle_kb_append, kb=kb, job_tracker=job_tracker), "kb_add_vector_db": partial(_handle_kb_add_vector_db, kb=kb), "kb_job_status": partial(_handle_kb_job_status, job_tracker=job_tracker), - "kb_remember": partial(_handle_kb_remember, kb=kb, memory=memory, suggestions=suggestions), - "kb_get_memories": partial(_handle_kb_get_memories, memory=memory, suggestions=suggestions), - "kb_get_suggestions": partial(_handle_kb_get_suggestions, suggestions=suggestions), - "kb_dismiss_suggestion": partial(_handle_kb_dismiss_suggestion, suggestions=suggestions), + "kb_remember": partial( + _handle_kb_remember, kb=kb, memory=memory, suggestions=suggestions + ), + "kb_get_memories": partial( + _handle_kb_get_memories, memory=memory, suggestions=suggestions + ), + "kb_get_suggestions": partial( + _handle_kb_get_suggestions, suggestions=suggestions + ), + "kb_dismiss_suggestion": partial( + _handle_kb_dismiss_suggestion, suggestions=suggestions + ), } @server.list_tools() async def list_tools() -> list[types.Tool]: return [ + types.Tool( + name="add_skill_source", + description=( + "Enable an external agent-skill source (a known name like " + "'scientific'/'anthropic'/'antigravity'/'composio', or a GitHub URL). " + "Clones it and indexes its skills into the searchable catalog " + "(search_skills). Does NOT load them into context." + ), + inputSchema={ + "type": "object", + "properties": { + "source": { + "type": "string", + "description": "Known source name or GitHub repo URL / owner/repo", + }, + }, + "required": ["source"], + }, + ), + types.Tool( + name="list_skill_sources", + description="List known + synced external skill sources and their indexed catalogs.", + inputSchema={"type": "object", "properties": {}}, + ), types.Tool( name="kb_list_collections", description=( @@ -706,13 +854,34 @@ async def list_tools() -> list[types.Tool]: inputSchema={ "type": "object", "properties": { - "collection_name": {"type": "string", "description": "Unique name for this collection"}, - "vector_db": {"type": "string", "enum": ["chroma", "lancedb", "qdrant"], "description": "Vector store backend type"}, - "connection_params": {"type": "object", "description": "Backend-specific connection parameters."}, - "embedding_model": {"type": "string", "description": "The API model used to build this index"}, - "description": {"type": "string", "description": "Human-readable description for agent discovery"}, + "collection_name": { + "type": "string", + "description": "Unique name for this collection", + }, + "vector_db": { + "type": "string", + "enum": ["chroma", "lancedb", "qdrant"], + "description": "Vector store backend type", + }, + "connection_params": { + "type": "object", + "description": "Backend-specific connection parameters.", + }, + "embedding_model": { + "type": "string", + "description": "The API model used to build this index", + }, + "description": { + "type": "string", + "description": "Human-readable description for agent discovery", + }, }, - "required": ["collection_name", "vector_db", "connection_params", "embedding_model"], + "required": [ + "collection_name", + "vector_db", + "connection_params", + "embedding_model", + ], }, ), types.Tool( @@ -721,7 +890,10 @@ async def list_tools() -> list[types.Tool]: inputSchema={ "type": "object", "properties": { - "job_id": {"type": "string", "description": "Job ID returned by kb_ingest or kb_append"}, + "job_id": { + "type": "string", + "description": "Job ID returned by kb_ingest or kb_append", + }, }, "required": ["job_id"], }, @@ -735,11 +907,26 @@ async def list_tools() -> list[types.Tool]: inputSchema={ "type": "object", "properties": { - "text": {"type": "string", "description": "The fact to remember"}, - "category": {"type": "string", "description": "Classification tag"}, - "session_id": {"type": "string", "description": "Current session identifier"}, - "supersedes": {"type": "string", "description": "entry_id of an existing memory this replaces"}, - "promoted_from": {"type": "string", "description": "suggestion_id if promoted from outlier suggestion"}, + "text": { + "type": "string", + "description": "The fact to remember", + }, + "category": { + "type": "string", + "description": "Classification tag", + }, + "session_id": { + "type": "string", + "description": "Current session identifier", + }, + "supersedes": { + "type": "string", + "description": "entry_id of an existing memory this replaces", + }, + "promoted_from": { + "type": "string", + "description": "suggestion_id if promoted from outlier suggestion", + }, }, "required": ["text"], }, @@ -766,7 +953,10 @@ async def list_tools() -> list[types.Tool]: inputSchema={ "type": "object", "properties": { - "suggestion_id": {"type": "string", "description": "ID of the suggestion to dismiss"}, + "suggestion_id": { + "type": "string", + "description": "ID of the suggestion to dismiss", + }, }, "required": ["suggestion_id"], }, @@ -783,7 +973,9 @@ async def call_tool(name: str, arguments: dict) -> list[types.TextContent]: except Exception as e: logger.exception("Unexpected error in tool '%s'", name) result = {"status": "error", "error": f"Unexpected error: {e}"} - return [types.TextContent(type="text", text=json.dumps(result, ensure_ascii=False))] + return [ + types.TextContent(type="text", text=json.dumps(result, ensure_ascii=False)) + ] return server @@ -792,6 +984,7 @@ async def call_tool(name: str, arguments: dict) -> list[types.TextContent]: # Entry point # --------------------------------------------------------------------------- + def main(): """Entry point for dsagt-knowledge-server. @@ -805,6 +998,7 @@ def main(): so cwd is project_dir for the MCP children it spawns. """ from dsagt.observability import find_project_config + project_dir, _ = find_project_config() if project_dir is None: raise RuntimeError( @@ -835,6 +1029,7 @@ def main(): # the config is broken and the server fails fast. config_path = project_dir / "dsagt_config.yaml" from dsagt.session import resolve_env_vars + config = resolve_env_vars(yaml.safe_load(config_path.read_text())) kb_config = config["knowledge"] @@ -894,7 +1089,10 @@ def main(): embedder_kwargs.update({"base_url": base_url, "api_key": api_key}) from dsagt.observability import init_tracing, configure_litellm_retries - init_tracing("dsagt-knowledge-server") # session_id picked up from DSAGT_SESSION_ID env + + init_tracing( + "dsagt-knowledge-server" + ) # session_id picked up from DSAGT_SESSION_ID env configure_litellm_retries() runtime_kb_dir = setup_runtime_kb(REGISTRY_DIR / "kb_index", project_dir) diff --git a/src/dsagt/commands/registry_server.py b/src/dsagt/commands/registry_server.py index 4202811..3d7fd80 100644 --- a/src/dsagt/commands/registry_server.py +++ b/src/dsagt/commands/registry_server.py @@ -40,10 +40,12 @@ ) from dsagt.provenance import reconstruct_pipeline from dsagt.registry import ( + CATALOG_COLLECTION_PREFIX, SKILLS_COLLECTION, TOOLS_COLLECTION, SkillRegistry, ToolRegistry, + _parse_frontmatter, ) os.environ["PYTHONUNBUFFERED"] = "1" @@ -55,12 +57,15 @@ # MCP server helpers # --------------------------------------------------------------------------- + async def _run_stdio(server: Server, name: str): async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): await server.run( - read_stream, write_stream, + read_stream, + write_stream, InitializationOptions( - server_name=name, server_version="0.1.0", + server_name=name, + server_version="0.1.0", capabilities=server.get_capabilities( notification_options=NotificationOptions(), experimental_capabilities={}, @@ -85,19 +90,27 @@ def _install_dependencies(packages: list[str], timeout: int = 120) -> str: except subprocess.TimeoutExpired: return f"Installation timed out after {timeout}s for: {', '.join(packages)}" except FileNotFoundError: - return "Error: 'uv' command not found. Install uv: https://github.com/astral-sh/uv" + return ( + "Error: 'uv' command not found. Install uv: https://github.com/astral-sh/uv" + ) # --------------------------------------------------------------------------- # Per-tool handlers (module-level, explicit dependencies) # --------------------------------------------------------------------------- + async def _handle_read_file(arguments: dict) -> str: path = Path(arguments["path"]) try: return path.read_text() - except (FileNotFoundError, PermissionError, IsADirectoryError, - OSError, UnicodeDecodeError) as e: + except ( + FileNotFoundError, + PermissionError, + IsADirectoryError, + OSError, + UnicodeDecodeError, + ) as e: return f"Error reading file: {e}" @@ -108,7 +121,10 @@ async def _handle_http_request(arguments: dict) -> str: try: async with httpx.AsyncClient(follow_redirects=True) as client: response = await client.request( - method=method, url=url, headers=headers, timeout=30.0, + method=method, + url=url, + headers=headers, + timeout=30.0, ) return f"Status: {response.status_code}\n\n{response.text}" except (httpx.HTTPError, httpx.InvalidURL) as e: @@ -122,7 +138,9 @@ async def _handle_run_command(arguments: dict) -> str: try: result = subprocess.run( [command] + args, - capture_output=True, text=True, timeout=timeout, + capture_output=True, + text=True, + timeout=timeout, ) except subprocess.TimeoutExpired: return f"Command timed out after {timeout} seconds" @@ -139,7 +157,9 @@ async def _handle_run_command(arguments: dict) -> str: async def _handle_save_tool_spec( - arguments: dict, *, registry: ToolRegistry, + arguments: dict, + *, + registry: ToolRegistry, ) -> str: spec = arguments["spec"] # Some MCP clients (notably Claude Sonnet/Haiku 4.x) serialize nested @@ -180,7 +200,9 @@ async def _handle_save_tool_spec( async def _handle_save_skill( - arguments: dict, *, skill_registry: SkillRegistry, + arguments: dict, + *, + skill_registry: SkillRegistry, ) -> str: """Register a skill (workflow / agent instructions) for later reuse. @@ -202,7 +224,9 @@ async def _handle_save_skill( except json.JSONDecodeError as e: return f"Error: reference_files must be a JSON object: {e}" try: - action = skill_registry.save_skill(spec, body=body, reference_files=reference_files) + action = skill_registry.save_skill( + spec, body=body, reference_files=reference_files + ) except (KeyError, ValueError, OSError) as e: return f"Error saving skill: {e}" skill_count = len(skill_registry.list_skills()) @@ -213,7 +237,9 @@ async def _handle_save_skill( async def _handle_get_registry( - arguments: dict, *, registry: ToolRegistry, + arguments: dict, + *, + registry: ToolRegistry, ) -> str: tools = registry.list_tools_raw() if not tools: @@ -222,7 +248,10 @@ async def _handle_get_registry( async def _handle_search_registry( - arguments: dict, *, registry: ToolRegistry, kb: KnowledgeBase | None, + arguments: dict, + *, + registry: ToolRegistry, + kb: KnowledgeBase | None, ) -> str: tool_name = arguments.get("tool_name") query = arguments.get("query", "") @@ -232,9 +261,8 @@ async def _handle_search_registry( if tool_name: tool = registry.get_tool(tool_name) if tool: - return ( - f"Found tool '{tool_name}':\n\n" - + yaml.dump(tool, default_flow_style=False, sort_keys=False) + return f"Found tool '{tool_name}':\n\n" + yaml.dump( + tool, default_flow_style=False, sort_keys=False ) return f"No tool named '{tool_name}'." @@ -255,7 +283,8 @@ async def _handle_search_registry( ) if tag and results: results = [ - r for r in results + r + for r in results if tag in r.get("chunk", {}).get("metadata", {}).get("tags", "") ][:top_k] if not results: @@ -287,9 +316,8 @@ async def _handle_search_skills( if skill_name and skill_registry: skill = skill_registry.get_skill(skill_name) if skill: - return ( - f"Found skill '{skill_name}':\n\n" - + yaml.dump(skill, default_flow_style=False, sort_keys=False) + return f"Found skill '{skill_name}':\n\n" + yaml.dump( + skill, default_flow_style=False, sort_keys=False ) return f"No skill named '{skill_name}'." @@ -301,17 +329,30 @@ async def _handle_search_skills( "skill_name for KB-free lookups." ) - # Single ``skills`` collection — bundled and registered entries. - results = kb.search( - query=query or "skill", - collection=SKILLS_COLLECTION, - top_k=top_k * 3 if tag else top_k, - ) - if tag and results: + # Search the installed/bundled ``skills`` collection AND every external + # ``skills_catalog__`` collection, then merge by score. Installed + # skills are also natively discovered by the agent; the catalog is the + # part native discovery can't do (it isn't loaded into context). + collections = [SKILLS_COLLECTION] + [ + c for c in kb.collections if c.startswith(CATALOG_COLLECTION_PREFIX) + ] + fetch_k = top_k * 3 if tag else top_k + results: list[dict] = [] + for coll in collections: + try: + results.extend( + kb.search(query=query or "skill", collection=coll, top_k=fetch_k) + ) + except (FileNotFoundError, KeyError, ValueError): + continue # collection absent/empty on this KB — skip + if tag: results = [ - r for r in results + r + for r in results if tag in r.get("chunk", {}).get("metadata", {}).get("tags", "") - ][:top_k] + ] + results.sort(key=lambda r: r.get("score", 0), reverse=True) + results = results[:top_k] if not results: return "No skills found matching the query." @@ -319,16 +360,65 @@ async def _handle_search_skills( for r in results: chunk = r.get("chunk", {}) meta = chunk.get("metadata", {}) + src = meta.get("source", "") + where = ( + " [installed]" + if src in ("bundled", "registered") + else ( + " [catalog · install_skill to add]" + if src.startswith("catalog:") + else "" + ) + ) summaries.append( - f"- **{meta.get('skill_name', 'unknown')}** " + f"- **{meta.get('skill_name', 'unknown')}**{where} " f"(score: {r.get('score', 0):.2f})\n" f" {chunk.get('text', '')[:200]}" ) return f"Found {len(results)} skill(s):\n\n" + "\n\n".join(summaries) +async def _handle_install_skill( + arguments: dict, + *, + skill_registry: SkillRegistry | None, + runtime_dir: Path, +) -> str: + """Install a catalog skill into ``/skills//``. + + The skill becomes natively discoverable after the next ``dsagt start`` + (which mirrors installed skills into ``.claude/skills/`` before launch). + """ + from dsagt.commands.skills_catalog import install_into_project + + name = arguments.get("skill_name") + if not name: + return "install_skill requires 'skill_name'." + try: + info = install_into_project(name, runtime_dir) + except LookupError as e: + return f"Error: {e}" + + # Index the now-installed skill as a project ('registered') skill too, so + # non-native agents can still find it via search_skills after install. + if skill_registry is not None and skill_registry._kb is not None: + skill_md = Path(info["dest_dir"]) / "SKILL.md" + spec = _parse_frontmatter(skill_md) + if spec.get("name"): + skill_registry._index_skill(spec, skill_md) + + return ( + f"{info['action'].capitalize()} skill '{info['name']}' at " + f"{info['dest_dir']}.\n\nIt will be available to the agent natively " + f"(.claude/skills/) on the next `dsagt start`; restart the agent to " + f"pick it up." + ) + + async def _handle_reconstruct_pipeline( - arguments: dict, *, runtime_dir: Path, + arguments: dict, + *, + runtime_dir: Path, ) -> str: fmt = arguments.get("format", "bash") trace_dir = runtime_dir / "trace_archive" @@ -343,7 +433,9 @@ async def _handle_reconstruct_pipeline( async def _handle_install_dependencies( - arguments: dict, *, registry: ToolRegistry, + arguments: dict, + *, + registry: ToolRegistry, ) -> str: tool_name = arguments.get("tool_name") tools = registry.list_tools_raw() @@ -383,6 +475,7 @@ async def _handle_install_dependencies( # Server factory (thin wiring — used by main() and tests) # --------------------------------------------------------------------------- + def create_registry_server( registry: ToolRegistry, kb: KnowledgeBase | None = None, @@ -407,9 +500,20 @@ def create_registry_server( "save_skill": partial(_handle_save_skill, skill_registry=skill_registry), "get_registry": partial(_handle_get_registry, registry=registry), "search_registry": partial(_handle_search_registry, registry=registry, kb=kb), - "search_skills": partial(_handle_search_skills, kb=kb, skill_registry=skill_registry), - "reconstruct_pipeline": partial(_handle_reconstruct_pipeline, runtime_dir=runtime_dir), - "install_dependencies": partial(_handle_install_dependencies, registry=registry), + "search_skills": partial( + _handle_search_skills, kb=kb, skill_registry=skill_registry + ), + "install_skill": partial( + _handle_install_skill, + skill_registry=skill_registry, + runtime_dir=runtime_dir, + ), + "reconstruct_pipeline": partial( + _handle_reconstruct_pipeline, runtime_dir=runtime_dir + ), + "install_dependencies": partial( + _handle_install_dependencies, registry=registry + ), } @server.list_tools() @@ -421,7 +525,10 @@ async def list_tools() -> list[types.Tool]: inputSchema={ "type": "object", "properties": { - "path": {"type": "string", "description": "Path to the file to read"}, + "path": { + "type": "string", + "description": "Path to the file to read", + }, }, "required": ["path"], }, @@ -433,8 +540,15 @@ async def list_tools() -> list[types.Tool]: "type": "object", "properties": { "url": {"type": "string", "description": "URL to request"}, - "method": {"type": "string", "description": "HTTP method", "default": "GET"}, - "headers": {"type": "object", "description": "Optional headers"}, + "method": { + "type": "string", + "description": "HTTP method", + "default": "GET", + }, + "headers": { + "type": "object", + "description": "Optional headers", + }, }, "required": ["url"], }, @@ -445,7 +559,10 @@ async def list_tools() -> list[types.Tool]: inputSchema={ "type": "object", "properties": { - "command": {"type": "string", "description": "Command to execute"}, + "command": { + "type": "string", + "description": "Command to execute", + }, "args": { "type": "array", "items": {"type": "string"}, @@ -474,19 +591,33 @@ async def list_tools() -> list[types.Tool]: { "type": "object", "properties": { - "name": {"type": "string", "description": "Unique tool identifier"}, - "description": {"type": "string", "description": "What the tool does"}, - "executable": {"type": "string", "description": "Command to execute"}, + "name": { + "type": "string", + "description": "Unique tool identifier", + }, + "description": { + "type": "string", + "description": "What the tool does", + }, + "executable": { + "type": "string", + "description": "Command to execute", + }, "parameters": { "type": "object", "description": "Parameter definitions", "additionalProperties": { "type": "object", "properties": { - "type": {"type": "string", "description": "Parameter type"}, + "type": { + "type": "string", + "description": "Parameter type", + }, "required": {"type": "boolean"}, "description": {"type": "string"}, - "default": {"description": "Default value"}, + "default": { + "description": "Default value" + }, "cli": { "type": "string", "description": ( @@ -512,7 +643,12 @@ async def list_tools() -> list[types.Tool]: "description": "Tags for categorizing the tool", }, }, - "required": ["name", "description", "executable", "parameters"], + "required": [ + "name", + "description", + "executable", + "parameters", + ], }, {"type": "string"}, ], @@ -543,8 +679,14 @@ async def list_tools() -> list[types.Tool]: { "type": "object", "properties": { - "name": {"type": "string", "description": "Unique skill identifier (becomes the directory name)"}, - "description": {"type": "string", "description": "What the skill does / when to use it"}, + "name": { + "type": "string", + "description": "Unique skill identifier (becomes the directory name)", + }, + "description": { + "type": "string", + "description": "What the skill does / when to use it", + }, "tags": { "type": "array", "items": {"type": "string"}, @@ -572,7 +714,10 @@ async def list_tools() -> list[types.Tool]: "path -> file contents, or JSON-encoded string." ), "anyOf": [ - {"type": "object", "additionalProperties": {"type": "string"}}, + { + "type": "object", + "additionalProperties": {"type": "string"}, + }, {"type": "string"}, ], }, @@ -593,24 +738,52 @@ async def list_tools() -> list[types.Tool]: "properties": { "query": {"type": "string", "description": "Search query"}, "tag": {"type": "string", "description": "Filter by tag"}, - "tool_name": {"type": "string", "description": "Exact tool name lookup"}, + "tool_name": { + "type": "string", + "description": "Exact tool name lookup", + }, "top_k": {"type": "integer", "default": 10}, }, }, ), types.Tool( name="search_skills", - description="Search for agent skills (workflows, templates) by name, tag, or description.", + description=( + "Search agent skills by name, tag, or description. Spans installed " + "skills and the external installable catalog. Catalog hits are marked " + "'[catalog]' — use install_skill to add one to this project." + ), inputSchema={ "type": "object", "properties": { "query": {"type": "string", "description": "Search query"}, "tag": {"type": "string", "description": "Filter by tag"}, - "skill_name": {"type": "string", "description": "Exact skill name lookup"}, + "skill_name": { + "type": "string", + "description": "Exact skill name lookup", + }, "top_k": {"type": "integer", "default": 10}, }, }, ), + types.Tool( + name="install_skill", + description=( + "Install a skill from the external catalog (found via search_skills) " + "into this project so the agent can use it natively. Copies SKILL.md " + "+ scripts/references; available natively after the next restart." + ), + inputSchema={ + "type": "object", + "properties": { + "skill_name": { + "type": "string", + "description": "Catalog skill name to install", + }, + }, + "required": ["skill_name"], + }, + ), types.Tool( name="reconstruct_pipeline", description="Reconstruct a reproducible pipeline script from tool execution records.", @@ -631,7 +804,10 @@ async def list_tools() -> list[types.Tool]: inputSchema={ "type": "object", "properties": { - "tool_name": {"type": "string", "description": "Install deps for a specific tool (omit for all)"}, + "tool_name": { + "type": "string", + "description": "Install deps for a specific tool (omit for all)", + }, }, }, ), @@ -650,6 +826,7 @@ async def call_tool(name: str, arguments: dict) -> list[types.TextContent]: # Entry point # --------------------------------------------------------------------------- + def main(): """Entry point for dsagt-registry-server. @@ -693,12 +870,16 @@ def main(): config_path = project_dir / "dsagt_config.yaml" from dsagt.session import resolve_env_vars + config = resolve_env_vars(yaml.safe_load(config_path.read_text())) emb_config = config["embedding"] from dsagt.observability import init_tracing, configure_litellm_retries - init_tracing("dsagt-registry-server") # session_id picked up from DSAGT_SESSION_ID env + + init_tracing( + "dsagt-registry-server" + ) # session_id picked up from DSAGT_SESSION_ID env configure_litellm_retries() # The KB is optional for the registry server — most tools (save_tool_spec, @@ -729,13 +910,14 @@ def main(): else: # backend == "api" api_key = emb_config.get("api_key") or "" kb_available = ( - api_key and not api_key.startswith("${") - and emb_config.get("base_url") + api_key and not api_key.startswith("${") and emb_config.get("base_url") + ) + embedder_kwargs.update( + { + "base_url": emb_config.get("base_url") or "", + "api_key": api_key, + } ) - embedder_kwargs.update({ - "base_url": emb_config.get("base_url") or "", - "api_key": api_key, - }) kb = None if kb_available: diff --git a/src/dsagt/commands/setup_core_kb.py b/src/dsagt/commands/setup_core_kb.py index 9cf977c..0fa493b 100644 --- a/src/dsagt/commands/setup_core_kb.py +++ b/src/dsagt/commands/setup_core_kb.py @@ -22,7 +22,6 @@ import os import shutil import subprocess -import sys import tarfile import tempfile from pathlib import Path @@ -135,7 +134,9 @@ } -def clone_github(url: str, dest: Path, branch: str = "main", include: list[str] | None = None): +def clone_github( + url: str, dest: Path, branch: str = "main", include: list[str] | None = None +): """Clone a GitHub repo, optionally keeping only specific directories. When *include* is set, the named subdirectories are copied AND any @@ -175,7 +176,7 @@ def clone_github(url: str, dest: Path, branch: str = "main", include: list[str] def download_arxiv(paper_id: str, dest: Path): """Download arXiv paper (source if available, else PDF).""" client = httpx.Client(timeout=60.0, follow_redirects=True) - + try: # Try source tarball first response = client.get(f"https://arxiv.org/e-print/{paper_id}") @@ -190,7 +191,7 @@ def download_arxiv(paper_id: str, dest: Path): return except tarfile.ReadError: tar_path.unlink() - + # Fall back to PDF response = client.get(f"https://arxiv.org/pdf/{paper_id}.pdf") response.raise_for_status() @@ -248,6 +249,7 @@ def setup_collection( owned_kb = kb is None if owned_kb: from dsagt.knowledge import KnowledgeBase + kb = KnowledgeBase( index_dir=index_dir, default_embedder=embedding_backend, @@ -257,7 +259,8 @@ def setup_collection( try: result = kb.ingest( download_dir, - exclude_patterns=config.get("exclude_patterns") or DEFAULT_EXCLUDE_PATTERNS, + exclude_patterns=config.get("exclude_patterns") + or DEFAULT_EXCLUDE_PATTERNS, ) skipped = result.get("skipped_files", 0) miss_msg = f", {skipped} file misses" if skipped else "" @@ -275,6 +278,7 @@ def _current_dsagt_version() -> str: """Return the installed dsagt package version, or ``"unknown"`` if absent.""" try: from importlib.metadata import version + return version("dsagt") except Exception: return "unknown" @@ -310,15 +314,18 @@ def add_setup_kb_args(parser): ), ) parser.add_argument( - "--embedding-model", default=None, + "--embedding-model", + default=None, help="Embedding model name (falls back to EMBEDDING_MODEL env var)", ) parser.add_argument( - "--embedding-base-url", default=None, + "--embedding-base-url", + default=None, help="Embedding API base URL (falls back to OPENAI_BASE_URL env var)", ) parser.add_argument( - "--embedding-api-key", default=None, + "--embedding-api-key", + default=None, help="Embedding API key (falls back to LLM_API_KEY / OPENAI_API_KEY env var)", ) parser.add_argument( @@ -333,6 +340,12 @@ def add_setup_kb_args(parser): help="Re-ingest collections that already exist in the index directory " "(default: skip existing).", ) + parser.add_argument( + "--no-skill-catalog", + action="store_true", + help="Skip cloning + indexing the default external skill catalog " + "(the K-Dense scientific skills repo).", + ) def run_setup_kb(args): @@ -352,6 +365,7 @@ def run_setup_kb(args): # ``force``, the second basicConfig is a no-op because the root # logger already has handlers, and the INFO-level chatter survives. import logging as _logging + _logging.basicConfig( level=_logging.WARNING, format="%(levelname)s: %(message)s", @@ -368,10 +382,18 @@ def run_setup_kb(args): # clear error up front rather than 5 minutes into the first ingest. embedder_kwargs: dict = {} if args.embedding_backend == "api": - api_key = args.embedding_api_key or os.getenv("LLM_API_KEY") or os.getenv("OPENAI_API_KEY") + api_key = ( + args.embedding_api_key + or os.getenv("LLM_API_KEY") + or os.getenv("OPENAI_API_KEY") + ) base_url = args.embedding_base_url or os.getenv("OPENAI_BASE_URL") model = args.embedding_model or os.getenv("EMBEDDING_MODEL") - missing = [n for n, v in [("api key", api_key), ("base URL", base_url), ("model", model)] if not v] + missing = [ + n + for n, v in [("api key", api_key), ("base URL", base_url), ("model", model)] + if not v + ] if missing: raise ValueError( "API embedding backend requires " @@ -388,6 +410,7 @@ def run_setup_kb(args): # to, so we skip init_tracing entirely. @traced decorators inside # KnowledgeBase see no backend and short-circuit cleanly. from dsagt.observability import configure_litellm_retries + configure_litellm_retries() # One KnowledgeBase per setup-kb invocation. The embedder cache @@ -399,9 +422,13 @@ def run_setup_kb(args): args.index_dir.mkdir(parents=True, exist_ok=True) from dsagt.knowledge import KnowledgeBase from dsagt.registry import ( - SKILLS_COLLECTION, TOOLS_COLLECTION, - ToolRegistry, SkillRegistry, _parse_frontmatter, + SKILLS_COLLECTION, + TOOLS_COLLECTION, + ToolRegistry, + SkillRegistry, + _parse_frontmatter, ) + shared_kb = KnowledgeBase( index_dir=args.index_dir, default_embedder=args.embedding_backend, @@ -415,11 +442,13 @@ def run_setup_kb(args): current_version = _current_dsagt_version() tool_paths = [ - p for p in sorted(ToolRegistry._PACKAGE_TOOLS_DIR.glob("*.md")) + p + for p in sorted(ToolRegistry._PACKAGE_TOOLS_DIR.glob("*.md")) if _parse_frontmatter(p).get("name") ] skill_dirs = [ - d for d in sorted(SkillRegistry._PACKAGE_SKILLS_DIR.iterdir()) + d + for d in sorted(SkillRegistry._PACKAGE_SKILLS_DIR.iterdir()) if d.is_dir() and (d / "SKILL.md").exists() and _parse_frontmatter(d / "SKILL.md").get("name") @@ -435,14 +464,17 @@ def run_setup_kb(args): shared_kb.add_entries( texts=[p.read_text() for p in tool_paths], collection=TOOLS_COLLECTION, - metadatas=[{ - "tool_name": s["name"], - "tags": ",".join(s.get("tags", [])), - "executable": s.get("executable", ""), - "has_dependencies": str(bool(s.get("dependencies"))), - "source": "bundled", - "dsagt_version": current_version, - } for s in tool_specs], + metadatas=[ + { + "tool_name": s["name"], + "tags": ",".join(s.get("tags", [])), + "executable": s.get("executable", ""), + "has_dependencies": str(bool(s.get("dependencies"))), + "source": "bundled", + "dsagt_version": current_version, + } + for s in tool_specs + ], ) if skill_dirs: @@ -450,28 +482,60 @@ def run_setup_kb(args): shared_kb.add_entries( texts=[(d / "SKILL.md").read_text() for d in skill_dirs], collection=SKILLS_COLLECTION, - metadatas=[{ - "skill_name": s["name"], - "tags": ",".join(s.get("tags", [])), - "source": "bundled", - "dsagt_version": current_version, - } for s in skill_specs], + metadatas=[ + { + "skill_name": s["name"], + "tags": ",".join(s.get("tags", [])), + "source": "bundled", + "dsagt_version": current_version, + } + for s in skill_specs + ], ) print(" bundled tools + skills: indexed", flush=True) - collections = {args.collection: COLLECTIONS[args.collection]} if args.collection else COLLECTIONS + # External skill catalog: clone + index the default source(s) so + # ``search_skills`` can browse installable skills out of the box. + # Best-effort — a clone failure (offline, repo moved) warns and + # continues rather than aborting the whole KB build. + if not getattr(args, "no_skill_catalog", False): + from dsagt.commands.skills_catalog import sync_source + from dsagt.session import DEFAULTS + + for src in DEFAULTS["skills"]["sources"]: + try: + stats = sync_source(src, kb=shared_kb, force=args.rebuild) + print( + f" skill catalog {stats['slug']}: {stats['indexed']} indexed", + flush=True, + ) + except Exception as e: # noqa: BLE001 — best-effort, keep going + print( + f" skill catalog {src.get('url', src)}: skipped ({e})", + flush=True, + ) + + collections = ( + {args.collection: COLLECTIONS[args.collection]} + if args.collection + else COLLECTIONS + ) for name, config in collections.items(): target_dir = args.index_dir / name if _collection_exists(target_dir): if not args.rebuild: - print(f" {name}: already indexed (use --rebuild to force)", - flush=True) + print( + f" {name}: already indexed (use --rebuild to force)", + flush=True, + ) continue shutil.rmtree(target_dir) setup_collection( - name, config, args.index_dir, + name, + config, + args.index_dir, embedder_kwargs=embedder_kwargs, embedding_backend=args.embedding_backend, vector_db=args.vector_db, diff --git a/src/dsagt/commands/skills_catalog.py b/src/dsagt/commands/skills_catalog.py new file mode 100644 index 0000000..c375a3b --- /dev/null +++ b/src/dsagt/commands/skills_catalog.py @@ -0,0 +1,277 @@ +""" +External skill catalog — fetch Agent-Skills repos, index, install. + +Two tiers (see the skill-management plan): + +* **Catalog** — every skill in a configured GitHub source repo, indexed + into a per-source ``skills_catalog__`` KB collection. Searchable + via ``search_skills``, but NOT copied locally and NOT loaded into the + agent's context. This is the one job native skill discovery can't do + (you can't hold thousands of skill descriptions in context). +* **Installed** — a chosen skill copied into ``/skills//``. + The agent setup then mirrors it into ``.claude/skills/`` for native + discovery (see ``agents.base._mirror_skills_to``). + +Re-sync is idempotent by dropping the per-source collection directory and +rebuilding it — no delete-by-metadata primitive required. + +``clone_github`` is imported lazily inside :func:`sync_source` to avoid an +import cycle with ``setup_core_kb`` (which calls back into ``sync_source``). +""" + +from __future__ import annotations + +import logging +import re +import shutil +from pathlib import Path + +from dsagt.registry import _parse_frontmatter, catalog_collection +from dsagt.session import REGISTRY_DIR + +logger = logging.getLogger(__name__) + +#: Default source enabled out of the box (matches dsagt_config.yaml default). +DEFAULT_SOURCE = "scientific" + +#: Curated, named skill sources. ``subdir`` scopes the recursive SKILL.md +#: walk when set (cheaper clone); when omitted the whole repo is cloned and +#: walked, which is robust to category-nested layouts. +KNOWN_SOURCES: dict[str, dict] = { + "scientific": { + "url": "https://github.com/K-Dense-AI/scientific-agent-skills", + "branch": "main", + "subdir": "skills", + "description": "K-Dense scientific agent skills — chem/bio/medicine/materials (140+).", + }, + "anthropic": { + "url": "https://github.com/anthropics/skills", + "branch": "main", + "subdir": "skills", + "description": "Official Anthropic skills + document-editing examples.", + }, + "antigravity": { + "url": "https://github.com/sickn33/antigravity-awesome-skills", + "branch": "main", + "subdir": None, + "description": "Antigravity Awesome Skills — 1,500+ cross-platform agentic skills.", + }, + "composio": { + "url": "https://github.com/ComposioHQ/awesome-claude-skills", + "branch": "master", + "subdir": None, + "description": "Composio awesome-claude-skills — workflow skills for many SaaS apps.", + }, +} + +#: Shared, machine-global cache of cloned source repos (sibling of kb_index/). +SKILL_SOURCES_DIR = REGISTRY_DIR / ".skill_sources" + + +# --------------------------------------------------------------------------- +# Source resolution + slugging +# --------------------------------------------------------------------------- + + +def resolve_source(source: str | dict) -> dict: + """Resolve a known-source name, a GitHub URL, or a full spec dict. + + Returns a dict with at least ``url``; optional ``branch`` / ``subdir``. + """ + if isinstance(source, dict): + if not source.get("url"): + raise ValueError("source dict must include a 'url'") + return source + if source in KNOWN_SOURCES: + return dict(KNOWN_SOURCES[source]) + if source.startswith(("http://", "https://", "git@")) or source.count("/") == 1: + # Full URL or ``owner/repo`` shorthand. + url = ( + source + if "://" in source or source.startswith("git@") + else f"https://github.com/{source}" + ) + return {"url": url, "branch": "main", "subdir": None} + raise ValueError( + f"Unknown skill source '{source}'. Use a known name " + f"({', '.join(sorted(KNOWN_SOURCES))}), a GitHub URL, or owner/repo." + ) + + +def _repo_slug(url: str) -> str: + """Stable, collection-name-safe slug from a GitHub URL (``owner-repo``).""" + s = url.rstrip("/") + s = re.sub(r"^https?://github\.com/", "", s) + s = re.sub(r"^git@github\.com:", "", s) + s = re.sub(r"\.git$", "", s).lower() + s = re.sub(r"[^a-z0-9]+", "-", s).strip("-") + return s[:40] + + +# --------------------------------------------------------------------------- +# Discovery +# --------------------------------------------------------------------------- + + +def _discover_skill_dirs(root: Path) -> list[Path]: + """Recursively find skill directories (any dir holding a parseable SKILL.md). + + Recursive so both flat (``skills//SKILL.md``) and category-nested + (``skills///SKILL.md``) repo layouts work. A directory + qualifies only if its SKILL.md has YAML frontmatter with a ``name``. + """ + out: list[Path] = [] + if not root.exists(): + return out + for skill_md in sorted(root.rglob("SKILL.md")): + try: + spec = _parse_frontmatter(skill_md) + except ValueError as e: # malformed frontmatter — skip, don't abort + logger.warning("skipping %s: %s", skill_md, e) + continue + if spec.get("name"): + out.append(skill_md.parent) + return out + + +# --------------------------------------------------------------------------- +# Sync (clone + index) +# --------------------------------------------------------------------------- + + +def sync_source( + source: str | dict, + *, + kb=None, + cache_dir: Path = SKILL_SOURCES_DIR, + force: bool = False, +) -> dict: + """Clone *source* into the cache and (re)index its skills into the catalog. + + ``force`` re-clones from scratch. Indexing wipes and rebuilds only this + source's ``skills_catalog__`` collection, so other catalogs and the + installed/bundled ``skills`` collection are untouched. When *kb* is None + the clone still happens (so ``install`` works offline-of-KB) but nothing + is indexed. + """ + spec = resolve_source(source) + slug = _repo_slug(spec["url"]) + dest = cache_dir / slug + + if force and dest.exists(): + shutil.rmtree(dest) + if not dest.exists(): + from dsagt.commands.setup_core_kb import clone_github # lazy: break cycle + + dest.mkdir(parents=True, exist_ok=True) + subdir = spec.get("subdir") + include = [subdir] if subdir else None + clone_github( + spec["url"], dest, branch=spec.get("branch", "main"), include=include + ) + + walk_root = dest / spec["subdir"] if spec.get("subdir") else dest + skill_dirs = _discover_skill_dirs(walk_root) + indexed = index_catalog(skill_dirs, slug, spec["url"], kb) if kb is not None else 0 + if kb is not None and not skill_dirs: + logger.warning( + "source %s yielded no SKILL.md skills under %s", spec["url"], walk_root + ) + + return { + "slug": slug, + "url": spec["url"], + "discovered": len(skill_dirs), + "indexed": indexed, + "cache_dir": str(dest), + } + + +def index_catalog(skill_dirs: list[Path], slug: str, url: str, kb) -> int: + """Wipe + rebuild source *slug*'s catalog collection from *skill_dirs*.""" + collection = catalog_collection(slug) + coll_dir = Path(kb.index_dir) / collection + if coll_dir.exists(): + shutil.rmtree(coll_dir) + + texts: list[str] = [] + metas: list[dict] = [] + for d in skill_dirs: + skill_md = d / "SKILL.md" + spec = _parse_frontmatter(skill_md) + name = spec.get("name") or d.name + texts.append(skill_md.read_text()) + metas.append( + { + "skill_name": name, + "tags": ",".join(spec.get("tags") or []), + "source": f"catalog:{slug}", + "source_url": url, + "cache_path": str(d), + } + ) + if texts: + kb.add_entries(texts=texts, collection=collection, metadatas=metas) + return len(texts) + + +# --------------------------------------------------------------------------- +# Lookup + install +# --------------------------------------------------------------------------- + + +def find_catalog_skill(name: str, *, cache_dir: Path = SKILL_SOURCES_DIR) -> Path: + """Locate a cached catalog skill dir by name across all synced sources. + + Matches on frontmatter ``name`` first, then directory name. Raises on + no match, or on an ambiguous match spanning more than one source repo. + """ + matches: list[Path] = [] + if cache_dir.exists(): + for slug_dir in sorted(p for p in cache_dir.iterdir() if p.is_dir()): + for d in _discover_skill_dirs(slug_dir): + spec = _parse_frontmatter(d / "SKILL.md") + if spec.get("name") == name or d.name == name: + matches.append(d) + if not matches: + raise LookupError( + f"No catalog skill named '{name}'. Run 'dsagt skills sync' or " + f"add_skill_source first, then search_skills to find one." + ) + # Collapse matches that point at the same source repo (slug = first path + # part under cache_dir); ambiguity only matters across different sources. + by_source = {p.relative_to(cache_dir).parts[0]: p for p in matches} + if len(by_source) > 1: + raise LookupError( + f"Skill '{name}' exists in multiple sources " + f"({', '.join(sorted(by_source))}); install by source with " + f"'dsagt skills add /{name}'." + ) + return next(iter(by_source.values())) + + +def install_into_project( + name: str, project_dir: str | Path, *, cache_dir: Path = SKILL_SOURCES_DIR +) -> dict: + """Copy a catalog skill into ``/skills//`` (with scripts/refs). + + The destination directory is named after the skill's frontmatter ``name`` + (falling back to its source dir name) so it matches the invocable name in + native discovery. Returns ``{name, source_dir, dest_dir, action}``. + """ + src = find_catalog_skill(name, cache_dir=cache_dir) + spec = _parse_frontmatter(src / "SKILL.md") + skill_name = spec.get("name") or src.name + + dest = Path(project_dir) / "skills" / skill_name + action = "updated" if dest.exists() else "added" + if dest.exists(): + shutil.rmtree(dest) + shutil.copytree(src, dest) + + return { + "name": skill_name, + "source_dir": str(src), + "dest_dir": str(dest), + "action": action, + } diff --git a/src/dsagt/dsagt_instructions.md b/src/dsagt/dsagt_instructions.md index d2bcfd6..385c762 100644 --- a/src/dsagt/dsagt_instructions.md +++ b/src/dsagt/dsagt_instructions.md @@ -25,6 +25,8 @@ Before implementing anything, search for existing capabilities: - `search_skills(query)` — find agent skills (workflows, templates, procedures) - `get_registry()` — list all registered tools +**Skills come in two tiers.** *Installed* skills (in this project) are discovered **natively** by your platform — their names/descriptions are already in your context and you auto-invoke them; you do NOT need `search_skills` to find those. Use `search_skills` to browse the much larger *external catalog* of installable skills (entries marked `[catalog]`), which are NOT loaded into context. To add a catalog skill to the project, call `install_skill(skill_name=...)`; it becomes natively available after the next session restart. To enable another catalog source (a known name like `scientific`/`anthropic`, or a GitHub URL), call `add_skill_source(source=...)`. To author a brand-new skill, use the bundled `skill-creator` skill. + **When the user indicates they want a specific tool used** — phrasings like "use tool `foo`", "use `foo` from the registry", "run `foo`", or similar — look it up first (`search_registry(tool_name=...)` for exact match, `get_registry()` to browse). Read the returned spec's `executable` field and each parameter's `cli` field, then invoke via your shell. Do not substitute your own file/shell tools for a task a registered tool can do. (See section 1b for the verbatim-`executable` rule.) **Rendering parameters**: each parameter's `cli` field pins exactly how its value goes on the command line. Emit positional args first (in position order), then named args. Skip optional parameters whose value is absent; use the `default` when present. diff --git a/src/dsagt/registry.py b/src/dsagt/registry.py index 7e91aed..a615672 100644 --- a/src/dsagt/registry.py +++ b/src/dsagt/registry.py @@ -34,6 +34,19 @@ TOOLS_COLLECTION = "tools" SKILLS_COLLECTION = "skills" +#: External skill catalogs (fetched from GitHub repos) live in their own +#: per-source collections named ``skills_catalog__``. Keeping each +#: source in its own collection lets a re-sync drop+rebuild one source's +#: directory without disturbing bundled/registered skills (the ``skills`` +#: collection) or other catalogs — no delete-by-metadata primitive needed. +CATALOG_COLLECTION_PREFIX = "skills_catalog__" + + +def catalog_collection(slug: str) -> str: + """KB collection name holding the indexed catalog for source *slug*.""" + return f"{CATALOG_COLLECTION_PREFIX}{slug}" + + #: Backwards-compat aliases — kept so external code that imported the #: previous names still resolves. New code should use the names above. TOOL_REGISTRY_COLLECTION = TOOLS_COLLECTION @@ -44,6 +57,7 @@ # Helpers (tools only) # --------------------------------------------------------------------------- + def _uv_run_prefix(deps: list[str]) -> str: """Build a 'uv run --with dep1,dep2 --' prefix for Python dependencies.""" if not deps: @@ -77,7 +91,9 @@ def _generate_tool_body(spec: dict) -> str: for name, p in params.items(): req = "yes" if p.get("required") else "no" default = p.get("default", "—") - lines.append(f"| `{name}` | {req} | {default} | {p.get('description', '')} |\n") + lines.append( + f"| `{name}` | {req} | {default} | {p.get('description', '')} |\n" + ) return "".join(lines) @@ -115,6 +131,7 @@ def _parse_frontmatter(path: Path) -> dict: # Parameters with `type: boolean` render as a bare flag when truthy and emit # nothing when falsy; positional booleans are not supported. + def _parse_cli(cli: str, param_name: str) -> dict: """Classify a cli string into a rendering descriptor. Fails fast on invalid input.""" if cli == "positional": @@ -187,6 +204,7 @@ def render_arguments(parameters: dict, values: dict) -> list[str]: # Tool Registry # --------------------------------------------------------------------------- + class ToolRegistry: """ Manages CLI tool spec files and optional KB indexing. @@ -283,15 +301,17 @@ def list_tools(self) -> list[dict]: properties[param_name]["default"] = param_def["default"] if param_def.get("required", False): required.append(param_name) - tools.append({ - "name": tool["name"], - "description": tool["description"], - "inputSchema": { - "type": "object", - "properties": properties, - "required": required, - }, - }) + tools.append( + { + "name": tool["name"], + "description": tool["description"], + "inputSchema": { + "type": "object", + "properties": properties, + "required": required, + }, + } + ) return tools def get_tool(self, name: str) -> dict | None: @@ -322,7 +342,9 @@ def save_tool(self, spec: dict) -> str: spec = dict(spec) spec["executable"] = _wrap_executable( - spec["name"], spec["executable"], spec.get("dependencies"), + spec["name"], + spec["executable"], + spec.get("dependencies"), ) # Preserve existing body when updating so hand-edited docs survive @@ -389,6 +411,7 @@ def reindex_all(self) -> int: # Skill Registry # --------------------------------------------------------------------------- + class SkillRegistry: """ Manages instruction-based agent skills and optional KB indexing. @@ -435,14 +458,16 @@ def _bundled_skill_dirs(self) -> list[Path]: if not self._bundled_dir.exists(): return [] return [ - d for d in sorted(self._bundled_dir.iterdir()) + d + for d in sorted(self._bundled_dir.iterdir()) if d.is_dir() and (d / "SKILL.md").exists() ] def _project_skill_dirs(self) -> list[Path]: """Return skill directories the agent has saved into this project.""" return [ - d for d in sorted(self.skills_dir.iterdir()) + d + for d in sorted(self.skills_dir.iterdir()) if d.is_dir() and (d / "SKILL.md").exists() ] diff --git a/src/dsagt/session.py b/src/dsagt/session.py index 6ae19b3..0679c2f 100644 --- a/src/dsagt/session.py +++ b/src/dsagt/session.py @@ -53,7 +53,7 @@ # ``dsagt setup-kb``). Migrated from ``~/.dsagt/`` on 2026-05-07. REGISTRY_DIR = DEFAULT_PROJECTS_BASE REGISTRY_FILE = REGISTRY_DIR / "projects.yaml" -RESERVED_PROJECT_NAMES = ("projects.yaml", "kb_index") +RESERVED_PROJECT_NAMES = ("projects.yaml", "kb_index", ".skill_sources") DEFAULTS = { # ``llm`` block uses ${VAR} placeholders so per-project config @@ -103,6 +103,24 @@ "vector_db": "chroma", "rerank": False, }, + # External agent-skill catalogs. ``sources`` are GitHub repos whose + # SKILL.md skills get indexed into per-source catalog collections for + # ``search_skills`` (searchable but NOT loaded into agent context). + # The agent installs a chosen one via the ``install_skill`` MCP tool; + # the agent setup then mirrors installed + bundled skills into the + # platform's native skill dir (e.g. ``.claude/skills/``). + "skills": { + "sources": [ + { + "name": "scientific", + "url": "https://github.com/K-Dense-AI/scientific-agent-skills", + "branch": "main", + "subdir": "skills", + }, + ], + "populate_catalog": True, # index sources into the catalog at setup-kb + "populate_native": True, # mirror installed+bundled into .claude/skills + }, } _ENV_VAR_RE = re.compile(r"\$\{(\w+)\}") @@ -112,6 +130,7 @@ # Config helpers # --------------------------------------------------------------------------- + def resolve_env_vars(value): """Replace ${VAR_NAME} references with environment variable values.""" if isinstance(value, str): @@ -157,6 +176,7 @@ def default_config_content( "knowledge": DEFAULTS["knowledge"], "categories": DEFAULTS["categories"], "extraction": DEFAULTS["extraction"], + "skills": DEFAULTS["skills"], } return yaml.dump(body, default_flow_style=False, sort_keys=False) @@ -165,6 +185,7 @@ def default_config_content( # Project registry # --------------------------------------------------------------------------- + def _load_registry() -> dict[str, str]: """Load the project registry. Returns empty dict if no registry exists.""" if not REGISTRY_FILE.exists(): @@ -190,6 +211,34 @@ def list_projects() -> dict[str, str]: return _load_registry() +def kb_from_config(config: dict, index_dir: Path | None = None) -> "KnowledgeBase": + """Build a KnowledgeBase from a resolved project config. + + Mirrors the embedding-backend resolution used by ``extract_session`` so + callers (CLI ``skills`` group, catalog sync) get a KB wired to the same + embedder the project uses. Defaults to ``/kb_index``. + """ + pdir = Path(config["project_dir"]) + emb = config.get("embedding", {}) + backend = emb.get("backend", "local") + if backend == "local": + model = emb.get("model") + if model and "/" not in str(model): + model = None + embedder_kwargs = {"model": model} + else: + embedder_kwargs = { + "model": emb.get("model"), + "base_url": emb.get("base_url"), + "api_key": os.environ.get("EMBEDDING_API_KEY", ""), + } + return KnowledgeBase( + index_dir=index_dir or (pdir / "kb_index"), + default_embedder=backend, + embedder_kwargs=embedder_kwargs, + ) + + def project_dir(name: str) -> Path: """Resolve a project name to its directory via the registry.""" registry = _load_registry() @@ -207,6 +256,7 @@ def project_dir(name: str) -> Path: # Config loading # --------------------------------------------------------------------------- + def load_config(project_name: str) -> dict: """Load and validate a project config by name. @@ -242,13 +292,16 @@ def _validate(config: dict) -> None: backend = config.get("mlflow", {}).get("backend") if backend and backend not in VALID_MLFLOW_BACKENDS: - raise ValueError(f"'mlflow.backend' must be one of {VALID_MLFLOW_BACKENDS}, got '{backend}'") + raise ValueError( + f"'mlflow.backend' must be one of {VALID_MLFLOW_BACKENDS}, got '{backend}'" + ) # --------------------------------------------------------------------------- # Project initialization # --------------------------------------------------------------------------- + def _collection_exists(path: Path) -> bool: """Return True if *path* looks like a persisted KB collection directory. @@ -256,14 +309,11 @@ def _collection_exists(path: Path) -> bool: collections, and bare ChromaDB sqlite files (as produced by ``dsagt setup-kb`` for description-only collections). """ - return ( - path.is_dir() - and ( - (path / "index.faiss").exists() - or (path / "chroma_ids.json").exists() - or (path / "route.json").exists() - or (path / "chroma.sqlite3").exists() - ) + return path.is_dir() and ( + (path / "index.faiss").exists() + or (path / "chroma_ids.json").exists() + or (path / "route.json").exists() + or (path / "chroma.sqlite3").exists() ) @@ -390,7 +440,9 @@ def persist_agent_choice(project_name: str, agent: str) -> None: "# ollama, mistral, groq, deepseek.\n" "# Full list: https://docs.litellm.ai/docs/providers\n" ) - yaml_path.write_text(header + yaml.dump(raw, default_flow_style=False, sort_keys=False)) + yaml_path.write_text( + header + yaml.dump(raw, default_flow_style=False, sort_keys=False) + ) def move_project(project_name: str, new_location: Path) -> Path: @@ -434,6 +486,7 @@ def remove_project(project_name: str, keep_files: bool = False) -> Path: # Service start / stop # --------------------------------------------------------------------------- + def _embedding_provider(config: dict) -> str: """Resolve embedding provider with a fallback for two cases: @@ -481,12 +534,20 @@ def mlflow_command(pdir: Path, mlflow_config: dict, port: int) -> list[str]: else str(mlflow_dir) ) return [ - sys.executable, "-m", "mlflow", "server", - "--backend-store-uri", backend_uri, - "--default-artifact-root", str(mlflow_dir / "artifacts"), - "--host", "0.0.0.0", - "--port", str(port), - "--workers", "1", + sys.executable, + "-m", + "mlflow", + "server", + "--backend-store-uri", + backend_uri, + "--default-artifact-root", + str(mlflow_dir / "artifacts"), + "--host", + "0.0.0.0", + "--port", + str(port), + "--workers", + "1", ] @@ -508,7 +569,10 @@ def _process_command(pid: int) -> str: try: result = subprocess.run( ["ps", "-p", str(pid), "-o", "command="], - capture_output=True, text=True, check=False, timeout=2.0, + capture_output=True, + text=True, + check=False, + timeout=2.0, ) except (FileNotFoundError, subprocess.TimeoutExpired): return "" @@ -555,7 +619,9 @@ def reap_runtime(runtime_file: Path) -> list[str]: for name, (pid, pgid) in pending.items(): try: os.killpg(pgid, signal.SIGKILL) - stopped.append(f"Stopped {name} (pid {pid}, SIGKILL after {_STOP_GRACE_SECONDS}s)") + stopped.append( + f"Stopped {name} (pid {pid}, SIGKILL after {_STOP_GRACE_SECONDS}s)" + ) except ProcessLookupError: stopped.append(f"Stopped {name} (pid {pid})") @@ -619,7 +685,8 @@ def start_services(config: dict) -> dict[str, int]: ) logger.info( "MLflow started (pid %d) → http://localhost:%d", - mlflow_proc.pid, mlflow_port, + mlflow_proc.pid, + mlflow_port, ) pids = {"mlflow": mlflow_proc.pid} @@ -635,11 +702,17 @@ def start_services(config: dict) -> dict[str, int]: pids["proxy"] = proxy_proc.pid ports["proxy"] = proxy_port - runtime_file.write_text(json.dumps({ - "pids": pids, - "ports": ports, - "started_at": datetime.now(timezone.utc).isoformat(), - }, indent=2) + "\n") + runtime_file.write_text( + json.dumps( + { + "pids": pids, + "ports": ports, + "started_at": datetime.now(timezone.utc).isoformat(), + }, + indent=2, + ) + + "\n" + ) if not proxy_requested: _wait_for_mlflow(mlflow_port, mlflow_proc, mlflow_log, timeout=30.0) @@ -650,7 +723,11 @@ def start_services(config: dict) -> dict[str, int]: def _start_proxy( - config: dict, pdir: Path, mlflow_port: int, proxy_port: int, session_id: str, + config: dict, + pdir: Path, + mlflow_port: int, + proxy_port: int, + session_id: str, ) -> subprocess.Popen: """Spawn the dsagt-proxy subprocess. @@ -671,15 +748,25 @@ def _start_proxy( ) cmd = [ - sys.executable, "-m", "dsagt.commands.proxy_server", - "--port", str(proxy_port), - "--mlflow-url", f"http://localhost:{mlflow_port}", - "--project", config["project"], - "--session", session_id, - "--records-dir", str(pdir / "trace_archive"), - "--model", llm["model"], - "--base-url", llm["base_url"], - "--provider", llm["provider"], + sys.executable, + "-m", + "dsagt.commands.proxy_server", + "--port", + str(proxy_port), + "--mlflow-url", + f"http://localhost:{mlflow_port}", + "--project", + config["project"], + "--session", + session_id, + "--records-dir", + str(pdir / "trace_archive"), + "--model", + llm["model"], + "--base-url", + llm["base_url"], + "--provider", + llm["provider"], ] # Embedding routing through the proxy is only relevant when the # project's embedding backend is ``api`` — in ``local`` mode the @@ -693,11 +780,16 @@ def _start_proxy( f"--enable-proxy with embedding.backend=api needs " f"config.embedding.{required} (got {emb.get(required)!r})" ) - cmd.extend([ - "--embedding-model", emb["model"], - "--embedding-base-url", emb["base_url"], - "--embedding-provider", emb["provider"], - ]) + cmd.extend( + [ + "--embedding-model", + emb["model"], + "--embedding-base-url", + emb["base_url"], + "--embedding-provider", + emb["provider"], + ] + ) proxy_log = pdir / "proxy.log" # The proxy needs the *real* upstream credentials in env (not the # sentinel agents see). os.environ already has them from the user's @@ -719,13 +811,17 @@ def _start_proxy( ) logger.info( "Proxy started (pid %d) → http://localhost:%d", - proxy_proc.pid, proxy_port, + proxy_proc.pid, + proxy_port, ) return proxy_proc def _wait_for_proxy( - port: int, proc: subprocess.Popen, log_path: Path, timeout: float = 45.0, + port: int, + proc: subprocess.Popen, + log_path: Path, + timeout: float = 45.0, ) -> None: """Poll *port* until the proxy answers, the subprocess dies, or we time out. @@ -755,7 +851,10 @@ def _wait_for_proxy( def _wait_for_mlflow( - port: int, proc: subprocess.Popen, log_path: Path, timeout: float = 30.0, + port: int, + proc: subprocess.Popen, + log_path: Path, + timeout: float = 30.0, ) -> None: """Poll *port* until MLflow answers, the subprocess dies, or we time out. @@ -787,11 +886,11 @@ def stop_services(project_name: str) -> list[str]: return reap_runtime(project_dir(project_name) / ".runtime") - # --------------------------------------------------------------------------- # Memory extraction orchestration # --------------------------------------------------------------------------- + def run_extraction(project_name: str) -> dict: """Two-phase post-session work, both best-effort. @@ -857,7 +956,8 @@ def run_extraction(project_name: str) -> dict: mlflow_port = config.get("mlflow", {}).get("port") mlflow_uri = ( - f"http://localhost:{mlflow_port}" if mlflow_port + f"http://localhost:{mlflow_port}" + if mlflow_port else os.environ.get("MLFLOW_TRACKING_URI") ) try: diff --git a/src/dsagt/skills/skill-creator/SKILL.md b/src/dsagt/skills/skill-creator/SKILL.md new file mode 100644 index 0000000..2f20e0f --- /dev/null +++ b/src/dsagt/skills/skill-creator/SKILL.md @@ -0,0 +1,65 @@ +--- +name: skill-creator +description: "Author a new agent Skill (a SKILL.md directory in the open Agent Skills format) from the Anthropic template. Use when the user wants to create a skill, scaffold a SKILL.md, package a repeatable workflow as a reusable skill, turn instructions into a skill, or capture a procedure so the agent can auto-invoke it later. Produces a valid SKILL.md (name + description frontmatter, optional scripts/ and references/) and saves it into the project's skills directory." +metadata: + version: "1.0" +--- + +# Skill Creator + +Scaffold a new, spec-valid agent Skill from the Anthropic template and save it so the agent can discover and auto-invoke it. + +A *skill* is a directory `/SKILL.md`: YAML frontmatter (`name`, `description`) plus markdown instructions the agent follows when the description matches the task. It can bundle `scripts/` and `references/`. See [references/agent_skills_spec.md](references/agent_skills_spec.md) for the full contract. + +## Workflow + +Copy this checklist and check off steps as you go: + +``` +Progress: +- [ ] 1. Gather skill intent (name, purpose, triggers) +- [ ] 2. Draft from the template +- [ ] 3. Write the body (instructions/workflow) +- [ ] 4. Add scripts/ and references/ if needed +- [ ] 5. Validate the frontmatter +- [ ] 6. Save into the project (save_skill) +- [ ] 7. Confirm + note how it activates +``` + +### 1. Gather Intent + +Ask the user (or infer from context): +- **name** — short, lowercase, hyphenated (e.g. `convert-vasp-outputs`). This becomes the directory name and the invocable name. +- **purpose** — one sentence on what the skill does. +- **triggers** — the user requests / phrasing that should make the agent reach for this skill. These become keywords in the `description`. + +### 2. Draft From the Template + +Start from [references/SKILL_template.md](references/SKILL_template.md). Fill the frontmatter: +- `name`: must equal the directory name. +- `description`: pack it with *what it does AND when to use it* (trigger phrases) — this is the only thing the agent sees when deciding to invoke. Keep it ≤ 1536 characters. + +### 3. Write the Body + +After the frontmatter, write the instructions the agent will follow. Prefer a copyable checklist (like this one) for multi-step workflows. Reference bundled files by relative path, e.g. `[reference](references/notes.md)`, or run a bundled script with `${CLAUDE_SKILL_DIR}/scripts/foo.py` so paths resolve regardless of cwd. + +### 4. Add Supporting Files (optional) + +- `scripts/` — runnable helpers the body invokes. +- `references/` — long docs/templates loaded on demand (keep them OUT of SKILL.md so they cost no tokens until used). + +### 5. Validate + +Confirm before saving: +- Frontmatter is valid YAML between `---` fences. +- `name` is present, lowercase-hyphenated, and equals the intended directory name. +- `description` is present and ≤ 1536 characters. +- Any `[link](references/...)` and `${CLAUDE_SKILL_DIR}/scripts/...` paths exist. + +### 6. Save + +Save via the **`save_skill`** MCP tool (registry server) with the `spec` (frontmatter dict: `name`, `description`, optional `tags`), the `body` markdown, and any `reference_files` (a `{relative_path: contents}` map). This writes `/skills//` and indexes it for `search_skills`. + +### 7. Confirm + +Tell the user the skill was saved and how it activates: project skills are mirrored into the platform's native skill directory (e.g. `.claude/skills/`) at the next `dsagt start`, after which the agent auto-discovers it. To use it in the current session, restart the agent. diff --git a/src/dsagt/skills/skill-creator/references/SKILL_template.md b/src/dsagt/skills/skill-creator/references/SKILL_template.md new file mode 100644 index 0000000..99db025 --- /dev/null +++ b/src/dsagt/skills/skill-creator/references/SKILL_template.md @@ -0,0 +1,53 @@ +# SKILL.md template + +Copy the block below into `/SKILL.md` and fill it in. Only the +frontmatter `name` + `description` are required; everything else is +optional. (Based on the Anthropic skill template / open Agent Skills +standard — https://github.com/anthropics/skills/tree/main/template.) + +```markdown +--- +name: my-skill-name +description: A clear description of WHAT this skill does and WHEN to use it — include the user phrasings/triggers that should invoke it. (≤ 1536 chars; this is the only text the agent sees when deciding to invoke.) +# optional: +# tags: [domain, keyword] +# metadata: +# version: "1.0" +--- + +# My Skill Name + +One or two sentences framing the task this skill handles. + +## Workflow + +Copy this checklist and check off steps as you go: + +``` +Progress: +- [ ] 1. ... +- [ ] 2. ... +``` + +### 1. ... + +Step-by-step instructions. Reference bundled docs by relative path: +[details](references/details.md). Run bundled scripts with an absolute +skill-dir path so cwd doesn't matter: + + python ${CLAUDE_SKILL_DIR}/scripts/helper.py + +## Notes / Guidelines +- ... +``` + +## Optional bundled files + +``` +my-skill-name/ +├── SKILL.md (required) +├── references/ (long docs/templates, loaded on demand) +│ └── details.md +└── scripts/ (runnable helpers the body invokes) + └── helper.py +``` diff --git a/src/dsagt/skills/skill-creator/references/agent_skills_spec.md b/src/dsagt/skills/skill-creator/references/agent_skills_spec.md new file mode 100644 index 0000000..bd3f79a --- /dev/null +++ b/src/dsagt/skills/skill-creator/references/agent_skills_spec.md @@ -0,0 +1,48 @@ +# Agent Skills — condensed contract + +A *skill* packages instructions (and optionally code/docs) so an agent can +discover and follow a repeatable workflow. dsagt skills follow the open +Agent Skills standard, which is what Claude Code, Cursor, Codex, and +Antigravity all read — so one SKILL.md works across platforms. + +## Directory layout + +``` +/ +├── SKILL.md # required — frontmatter + instructions +├── references/ # optional — docs/templates loaded on demand +└── scripts/ # optional — runnable helpers +``` + +- The **directory name is the invocable name** (e.g. `.claude/skills/deploy/` → `/deploy`). Keep it lowercase, hyphenated. + +## Frontmatter + +YAML between `---` fences. Common fields: + +| Field | Required | Notes | +|-------|----------|-------| +| `name` | recommended | Should equal the directory name. Lowercase-hyphenated. | +| `description` | **yes (in practice)** | What it does AND when to use it (trigger phrases). The agent sees only this when deciding to invoke. **≤ 1536 characters.** | +| `tags` | no | List of keywords; dsagt uses these for `search_skills` tag filters. | +| `metadata` | no | Free-form (e.g. `version`). Ignored by the platform. | +| `license` | no | Free-form. Ignored by the platform. | + +Unknown/extra frontmatter fields are **silently ignored** by Claude Code, so dsagt-specific fields are safe to include. + +## How discovery works + +- At session start, each installed skill's `name` + `description` are loaded into the agent's context. The full SKILL.md body loads only when the skill is invoked (lazy — zero cost until used). +- The agent auto-invokes a skill when the `description` matches the task; the user can also invoke it directly (`/skill-name`). +- A **newly created** top-level skills directory is only picked up after the agent restarts. + +## Body conventions + +- Lead with a copyable progress checklist for multi-step workflows. +- Keep long material in `references/` (loaded on demand) rather than inline, to save context tokens. +- Reference bundled files by relative path, or run scripts via `${CLAUDE_SKILL_DIR}/scripts/...` so paths resolve regardless of working directory. + +## Two tiers in dsagt + +- **Catalog** — skills indexed from external GitHub source repos, searchable via `search_skills` but not installed. Not in context. +- **Installed** — skills in `/skills/` (saved via `save_skill` or installed via `install_skill`). Mirrored into the platform's native skill dir at `dsagt start`, then natively discovered. diff --git a/tests/test_config.py b/tests/test_config.py index b616597..adfc439 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -164,6 +164,20 @@ def test_missing_project_raises(self, tmp_path): with pytest.raises(ValueError, match="project"): load_config(name) + def test_skills_block_backfilled_for_old_config(self, tmp_path): + """A config written before the skills block still gets the default.""" + name = self._write_config( + tmp_path, + "myproject", + {"project": "myproject", "agent": "claude", "llm": {"provider": "openai"}}, + ) + config = load_config(name) + sources = config["skills"]["sources"] + assert sources[0]["name"] == "scientific" + assert "K-Dense-AI" in sources[0]["url"] + assert config["skills"]["populate_native"] is True + assert config["skills"]["populate_catalog"] is True + def test_missing_agent_raises(self, tmp_path): name = self._write_config(tmp_path, "myproject", {"project": "myproject"}) with pytest.raises(ValueError, match="agent"): @@ -194,6 +208,18 @@ def test_project_dir_injected(self, tmp_path): assert config["project_dir"] == str(tmp_path / "myproject") +class TestSkillsDefaults: + + def test_defaults_has_skills(self): + from dsagt.session import DEFAULTS + + assert DEFAULTS["skills"]["sources"][0]["name"] == "scientific" + + def test_default_config_content_includes_skills(self): + body = yaml.safe_load(default_config_content("p", "claude", 5001)) + assert body["skills"]["sources"][0]["name"] == "scientific" + + # --------------------------------------------------------------------------- # Config: helpers # --------------------------------------------------------------------------- diff --git a/tests/test_knowledge_server.py b/tests/test_knowledge_server.py index 1504490..a05c35f 100644 --- a/tests/test_knowledge_server.py +++ b/tests/test_knowledge_server.py @@ -34,7 +34,9 @@ async def _call_tool_async(server, name: str, arguments: dict) -> dict: return json.loads(result.root.content[0].text) -async def call_tool_and_await_job(server, name: str, arguments: dict) -> tuple[dict, dict]: +async def call_tool_and_await_job( + server, name: str, arguments: dict +) -> tuple[dict, dict]: """Call a tool that starts a background job, wait for it, return (initial, final).""" initial = await _call_tool_async(server, name, arguments) assert initial["status"] == "started" @@ -50,7 +52,9 @@ async def call_tool_and_await_job(server, name: str, arguments: dict) -> tuple[d raise TimeoutError(f"Job {job_id} did not complete") -def make_search_result(text: str, source_file: str, chunk_index: int = 0, score: float = 0.9): +def make_search_result( + text: str, source_file: str, chunk_index: int = 0, score: float = 0.9 +): """Create a search result in the format KnowledgeBase.search returns.""" return { "chunk": { @@ -70,6 +74,7 @@ def make_search_result(text: str, source_file: str, chunk_index: int = 0, score: # Fixtures # --------------------------------------------------------------------------- + @pytest.fixture def mock_kb(tmp_path): """A mocked KnowledgeBase with default behaviors.""" @@ -86,7 +91,12 @@ def mock_kb(tmp_path): make_search_result("Second result text", "/path/to/file2.md", 1, 0.80), ] kb.ingest.return_value = {"collection": "new_docs", "files": 5, "chunks": 42} - kb.append.return_value = {"collection": "docs", "files": 2, "chunks_added": 10, "total_chunks": 50} + kb.append.return_value = { + "collection": "docs", + "files": 2, + "chunks_added": 10, + "total_chunks": 50, + } return kb @@ -100,6 +110,25 @@ def server(mock_kb): # kb_list_collections # --------------------------------------------------------------------------- + +class TestSkillSources: + + def test_list_skill_sources_returns_known(self, mock_kb): + mock_kb.collections = [] + server = create_knowledge_server(mock_kb) + result = call_tool(server, "list_skill_sources", {}) + assert "scientific" in result["known_sources"] + assert result["synced_collections"] == [] + + def test_add_skill_source_bad_source_errors(self, mock_kb): + mock_kb.collections = [] + server = create_knowledge_server(mock_kb) + result = call_tool( + server, "add_skill_source", {"source": "not-a-real-known-name"} + ) + assert "error" in result + + class TestListCollections: def test_returns_collections(self, server, mock_kb): @@ -128,14 +157,19 @@ def test_empty_collections(self, mock_kb): # kb_search # --------------------------------------------------------------------------- + class TestSearch: def test_search_success(self, server, mock_kb): """Successful search returns formatted results.""" - result = call_tool(server, "kb_search", { - "query": "how to install", - "collection": "docs", - }) + result = call_tool( + server, + "kb_search", + { + "query": "how to install", + "collection": "docs", + }, + ) assert result["status"] == "ok" assert result["query"] == "how to install" @@ -150,12 +184,16 @@ def test_search_success(self, server, mock_kb): def test_search_passes_parameters(self, server, mock_kb): """Search forwards top_k and rerank to the knowledge base.""" - call_tool(server, "kb_search", { - "query": "test", - "collection": "docs", - "top_k": 10, - "rerank": False, - }) + call_tool( + server, + "kb_search", + { + "query": "test", + "collection": "docs", + "top_k": 10, + "rerank": False, + }, + ) mock_kb.search.assert_called_once_with( query="test", @@ -166,10 +204,14 @@ def test_search_passes_parameters(self, server, mock_kb): def test_search_defaults(self, server, mock_kb): """Search uses default top_k=5 and server's use_rerank setting.""" - call_tool(server, "kb_search", { - "query": "test", - "collection": "docs", - }) + call_tool( + server, + "kb_search", + { + "query": "test", + "collection": "docs", + }, + ) mock_kb.search.assert_called_once_with( query="test", @@ -182,10 +224,14 @@ def test_search_nonexistent_collection(self, server, mock_kb): """Searching a missing collection returns an error.""" mock_kb.search.side_effect = ValueError("Collection 'missing' not found") - result = call_tool(server, "kb_search", { - "query": "test", - "collection": "missing", - }) + result = call_tool( + server, + "kb_search", + { + "query": "test", + "collection": "missing", + }, + ) assert result["status"] == "error" assert "not found" in result["error"] @@ -196,10 +242,14 @@ def test_search_rerank_score_forwarded(self, server, mock_kb): {**make_search_result("text", "file.md"), "rerank_score": 0.99}, ] - result = call_tool(server, "kb_search", { - "query": "test", - "collection": "docs", - }) + result = call_tool( + server, + "kb_search", + { + "query": "test", + "collection": "docs", + }, + ) assert result["results"][0]["rerank_score"] == 0.99 @@ -208,6 +258,7 @@ def test_search_rerank_score_forwarded(self, server, mock_kb): # kb_ingest (background job pattern) # --------------------------------------------------------------------------- + class TestIngest: def test_ingest_returns_started(self, server, mock_kb, tmp_path): @@ -215,9 +266,13 @@ def test_ingest_returns_started(self, server, mock_kb, tmp_path): folder = tmp_path / "new_docs" folder.mkdir() - result = call_tool(server, "kb_ingest", { - "folder_path": str(folder), - }) + result = call_tool( + server, + "kb_ingest", + { + "folder_path": str(folder), + }, + ) assert result["status"] == "started" assert "job_id" in result @@ -245,23 +300,31 @@ def test_ingest_with_file_types(self, server, mock_kb, tmp_path): async def run(): await call_tool_and_await_job( - server, "kb_ingest", { + server, + "kb_ingest", + { "folder_path": str(folder), "file_types": ["md", "txt"], - } + }, ) # New server always passes collection_name to kb.ingest mock_kb.ingest.assert_called_once_with( - folder, collection_name="docs2", file_types=["md", "txt"], + folder, + collection_name="docs2", + file_types=["md", "txt"], ) asyncio.run(run()) def test_ingest_folder_not_found(self, server): """Ingesting a nonexistent folder returns an error immediately.""" - result = call_tool(server, "kb_ingest", { - "folder_path": "/nonexistent/folder", - }) + result = call_tool( + server, + "kb_ingest", + { + "folder_path": "/nonexistent/folder", + }, + ) assert result["status"] == "error" assert "not found" in result["error"].lower() @@ -271,9 +334,13 @@ def test_ingest_not_a_directory(self, server, tmp_path): file_path = tmp_path / "not_a_dir.txt" file_path.write_text("I'm a file") - result = call_tool(server, "kb_ingest", { - "folder_path": str(file_path), - }) + result = call_tool( + server, + "kb_ingest", + { + "folder_path": str(file_path), + }, + ) assert result["status"] == "error" assert "Not a directory" in result["error"] @@ -308,9 +375,13 @@ def test_ingest_deconflicts_existing_collection(self, server, mock_kb, tmp_path) mock_kb.ingest.return_value = {"collection": "docs1", "files": 3, "chunks": 10} - result = call_tool(server, "kb_ingest", { - "folder_path": str(folder), - }) + result = call_tool( + server, + "kb_ingest", + { + "folder_path": str(folder), + }, + ) assert result["status"] == "started" assert result["collection"] == "docs1" @@ -331,9 +402,13 @@ def test_ingest_deconflicts_symlinked_collection(self, server, mock_kb, tmp_path (mock_kb.index_dir / "docs").symlink_to(base_dir) mock_kb.ingest.return_value = {"collection": "docs1", "files": 3, "chunks": 10} - result = call_tool(server, "kb_ingest", { - "folder_path": str(folder), - }) + result = call_tool( + server, + "kb_ingest", + { + "folder_path": str(folder), + }, + ) assert result["status"] == "started" assert result["collection"] == "docs1" @@ -347,6 +422,7 @@ def test_ingest_deconflicts_symlinked_collection(self, server, mock_kb, tmp_path # kb_job_status # --------------------------------------------------------------------------- + class TestJobStatus: def test_unknown_job(self, server): @@ -365,12 +441,17 @@ def test_running_job(self, server, mock_kb, tmp_path): def blocking_ingest(*args, **kwargs): time.sleep(10) return {"collection": "slow_docs", "files": 1, "chunks": 5} + mock_kb.ingest.side_effect = blocking_ingest async def run(): - initial = await _call_tool_async(server, "kb_ingest", { - "folder_path": str(folder), - }) + initial = await _call_tool_async( + server, + "kb_ingest", + { + "folder_path": str(folder), + }, + ) assert initial["status"] == "started" job_id = initial["job_id"] @@ -385,6 +466,7 @@ async def run(): # kb_append (background job pattern) # --------------------------------------------------------------------------- + class TestAppend: def test_append_returns_started(self, server, mock_kb, tmp_path): @@ -394,10 +476,14 @@ def test_append_returns_started(self, server, mock_kb, tmp_path): coll_dir.mkdir(exist_ok=True) (coll_dir / "index.faiss").write_text("fake") - result = call_tool(server, "kb_append", { - "collection": "docs", - "paths": [str(tmp_path)], - }) + result = call_tool( + server, + "kb_append", + { + "collection": "docs", + "paths": [str(tmp_path)], + }, + ) assert result["status"] == "started" assert "job_id" in result @@ -411,10 +497,12 @@ def test_append_job_completes(self, server, mock_kb, tmp_path): async def run(): initial, final = await call_tool_and_await_job( - server, "kb_append", { + server, + "kb_append", + { "collection": "docs", "paths": [str(tmp_path)], - } + }, ) assert final["status"] == "complete" assert final["result"]["chunks_added"] == 10 @@ -423,10 +511,14 @@ async def run(): def test_append_collection_not_found(self, server, mock_kb): """Appending to a nonexistent collection returns an error immediately.""" - result = call_tool(server, "kb_append", { - "collection": "nonexistent", - "paths": ["/some/path"], - }) + result = call_tool( + server, + "kb_append", + { + "collection": "nonexistent", + "paths": ["/some/path"], + }, + ) assert result["status"] == "error" assert "not found" in result["error"].lower() @@ -436,6 +528,7 @@ def test_append_collection_not_found(self, server, mock_kb): # kb_search — error handling (transport-closed diagnostics) # --------------------------------------------------------------------------- + class TestSearchErrorHandling: """Verify the server returns error responses (not crashes) for common failure modes that would otherwise cause 'transport closed'.""" @@ -443,12 +536,18 @@ class TestSearchErrorHandling: def test_search_httpx_connect_error(self, mock_kb): """Network unreachable during search returns error, not crash.""" import httpx + mock_kb.search.side_effect = httpx.ConnectError("Connection refused") server = create_knowledge_server(mock_kb) - result = call_tool(server, "kb_search", { - "query": "test", "collection": "docs", - }) + result = call_tool( + server, + "kb_search", + { + "query": "test", + "collection": "docs", + }, + ) assert result["status"] == "error" assert "Connection refused" in result["error"] @@ -456,12 +555,18 @@ def test_search_httpx_connect_error(self, mock_kb): def test_search_httpx_timeout(self, mock_kb): """Embedding API timeout during search returns error, not crash.""" import httpx + mock_kb.search.side_effect = httpx.ReadTimeout("Read timed out") server = create_knowledge_server(mock_kb) - result = call_tool(server, "kb_search", { - "query": "test", "collection": "docs", - }) + result = call_tool( + server, + "kb_search", + { + "query": "test", + "collection": "docs", + }, + ) assert result["status"] == "error" assert "timed out" in result["error"].lower() @@ -469,16 +574,24 @@ def test_search_httpx_timeout(self, mock_kb): def test_search_httpx_401(self, mock_kb): """Expired/invalid API key during search returns error, not crash.""" import httpx + mock_resp = MagicMock() mock_resp.status_code = 401 mock_kb.search.side_effect = httpx.HTTPStatusError( - "401 Unauthorized", request=MagicMock(), response=mock_resp, + "401 Unauthorized", + request=MagicMock(), + response=mock_resp, ) server = create_knowledge_server(mock_kb) - result = call_tool(server, "kb_search", { - "query": "test", "collection": "docs", - }) + result = call_tool( + server, + "kb_search", + { + "query": "test", + "collection": "docs", + }, + ) assert result["status"] == "error" assert "401" in result["error"] @@ -486,16 +599,24 @@ def test_search_httpx_401(self, mock_kb): def test_search_httpx_500(self, mock_kb): """Embedding API server error returns error, not crash.""" import httpx + mock_resp = MagicMock() mock_resp.status_code = 500 mock_kb.search.side_effect = httpx.HTTPStatusError( - "500 Internal Server Error", request=MagicMock(), response=mock_resp, + "500 Internal Server Error", + request=MagicMock(), + response=mock_resp, ) server = create_knowledge_server(mock_kb) - result = call_tool(server, "kb_search", { - "query": "test", "collection": "docs", - }) + result = call_tool( + server, + "kb_search", + { + "query": "test", + "collection": "docs", + }, + ) assert result["status"] == "error" assert "500" in result["error"] @@ -505,9 +626,14 @@ def test_search_runtime_error(self, mock_kb): mock_kb.search.side_effect = RuntimeError("FAISS segfault simulation") server = create_knowledge_server(mock_kb) - result = call_tool(server, "kb_search", { - "query": "test", "collection": "docs", - }) + result = call_tool( + server, + "kb_search", + { + "query": "test", + "collection": "docs", + }, + ) assert result["status"] == "error" assert "FAISS segfault" in result["error"] @@ -517,9 +643,14 @@ def test_search_os_error(self, mock_kb): mock_kb.search.side_effect = OSError("Permission denied: index.faiss") server = create_knowledge_server(mock_kb) - result = call_tool(server, "kb_search", { - "query": "test", "collection": "docs", - }) + result = call_tool( + server, + "kb_search", + { + "query": "test", + "collection": "docs", + }, + ) assert result["status"] == "error" assert "Permission denied" in result["error"] @@ -530,6 +661,7 @@ def test_search_os_error(self, mock_kb): # setup_runtime_kb # --------------------------------------------------------------------------- + class TestSetupRuntimeKb: def test_copies_collections(self, tmp_path): @@ -616,6 +748,7 @@ def test_does_not_overwrite_existing(self, tmp_path): # Regression: OpenMP duplicate library crash (transport closed) # --------------------------------------------------------------------------- + class TestOpenMPWorkaround: """Importing knowledge_server must set KMP_DUPLICATE_LIB_OK to prevent a fatal OpenMP crash when FAISS and sentence-transformers (PyTorch) @@ -636,6 +769,7 @@ def test_kmp_duplicate_lib_ok_is_set(self): # Regression: rerank schema default must match server config # --------------------------------------------------------------------------- + class TestRerankSchemaDefault: """The kb_search schema previously hardcoded 'default': True for the rerank parameter, causing agents to request reranking even when the @@ -665,10 +799,14 @@ def test_search_omitted_rerank_passes_none(self, mock_kb): """Omitting rerank passes None to kb.search, which resolves to kb.default_rerank internally.""" server = create_knowledge_server(mock_kb) - call_tool(server, "kb_search", { - "query": "test", - "collection": "docs", - }) + call_tool( + server, + "kb_search", + { + "query": "test", + "collection": "docs", + }, + ) mock_kb.search.assert_called_once_with( query="test", collection="docs", diff --git a/tests/test_registry_server.py b/tests/test_registry_server.py index a7e8cb2..81c5eb2 100644 --- a/tests/test_registry_server.py +++ b/tests/test_registry_server.py @@ -19,8 +19,12 @@ from mcp_helpers import call_tool_sync as call_tool -def make_spec(name="test_tool", description="A test tool", executable="echo hello", - dependencies=None): +def make_spec( + name="test_tool", + description="A test tool", + executable="echo hello", + dependencies=None, +): """Create a minimal valid tool spec.""" spec = { "name": name, @@ -59,7 +63,7 @@ def _make_server(tmp_path, tools=None): runtime_dir = tmp_path / "runtime" project_tools_dir = runtime_dir / "tools" project_tools_dir.mkdir(parents=True, exist_ok=True) - for spec in (tools or []): + for spec in tools or []: _write_tool(project_tools_dir, spec) reg = ToolRegistry( source_tools_dir=str(source_dir), @@ -72,6 +76,7 @@ def _make_server(tmp_path, tools=None): # Fixtures # --------------------------------------------------------------------------- + @pytest.fixture def server_and_registry(tmp_path): return _make_server(tmp_path) @@ -89,10 +94,13 @@ def registry(server_and_registry): @pytest.fixture def populated(tmp_path): - server, reg = _make_server(tmp_path, tools=[ - make_spec("tool_alpha", "Alpha tool", "python alpha.py"), - make_spec("tool_beta", "Beta data processor", "python beta.py"), - ]) + server, reg = _make_server( + tmp_path, + tools=[ + make_spec("tool_alpha", "Alpha tool", "python alpha.py"), + make_spec("tool_beta", "Beta data processor", "python beta.py"), + ], + ) return server, reg @@ -105,6 +113,7 @@ def populated_server(populated): # save_tool_spec # --------------------------------------------------------------------------- + class TestSaveToolSpec: def test_add_new_tool(self, server, registry): @@ -118,8 +127,16 @@ def test_add_new_tool(self, server, registry): def test_update_existing_tool(self, server, registry): """Saving a spec with the same name updates rather than duplicates.""" - call_tool(server, "save_tool_spec", {"spec": make_spec("my_tool", description="Version 1")}) - text = call_tool(server, "save_tool_spec", {"spec": make_spec("my_tool", description="Version 2")}) + call_tool( + server, + "save_tool_spec", + {"spec": make_spec("my_tool", description="Version 1")}, + ) + text = call_tool( + server, + "save_tool_spec", + {"spec": make_spec("my_tool", description="Version 2")}, + ) assert "updated" in text assert "1 tools" in text @@ -138,6 +155,7 @@ def test_accepts_stringified_spec(self, server, registry): """Some MCP clients (Claude Sonnet/Haiku 4.x) send nested-object args as JSON strings. The handler must accept both shapes.""" import json + spec = make_spec("stringy_tool") text = call_tool(server, "save_tool_spec", {"spec": json.dumps(spec)}) @@ -152,10 +170,29 @@ def test_rejects_invalid_stringified_spec(self, server, registry): assert "JSON object" in text +# --------------------------------------------------------------------------- +# install_skill +# --------------------------------------------------------------------------- + + +class TestInstallSkill: + + def test_install_skill_routes_and_reports_missing(self, server): + """install_skill is registered and reports a clean error when the + named skill isn't in any synced catalog.""" + text = call_tool( + server, + "install_skill", + {"skill_name": "zzz-definitely-not-a-real-skill-xyz"}, + ) + assert "No catalog skill" in text + + # --------------------------------------------------------------------------- # save_skill # --------------------------------------------------------------------------- + class TestSaveSkill: def test_add_new_skill_creates_files_and_indexes(self, tmp_path): @@ -168,6 +205,7 @@ def test_add_new_skill_creates_files_and_indexes(self, tmp_path): """ server, reg, kb = _make_server_with_kb(tmp_path) from dsagt.registry import SkillRegistry as _SR + skill_reg = _SR(runtime_dir=str(tmp_path / "runtime"), kb=kb) before = len(skill_reg.list_skills()) @@ -192,14 +230,22 @@ def test_update_existing_skill_preserves_body_when_omitted(self, tmp_path): """Saving a spec for an existing skill without body keeps the body.""" server, reg, kb = _make_server_with_kb(tmp_path) first_body = "# orig\n\nOriginal workflow body.\n" - call_tool(server, "save_skill", { - "spec": {"name": "wf", "description": "v1"}, - "body": first_body, - }) + call_tool( + server, + "save_skill", + { + "spec": {"name": "wf", "description": "v1"}, + "body": first_body, + }, + ) # Update the description only — body should be preserved. - text = call_tool(server, "save_skill", { - "spec": {"name": "wf", "description": "v2 description"}, - }) + text = call_tool( + server, + "save_skill", + { + "spec": {"name": "wf", "description": "v2 description"}, + }, + ) assert "updated" in text skill_md = tmp_path / "runtime" / "skills" / "wf" / "SKILL.md" content = skill_md.read_text() @@ -209,11 +255,15 @@ def test_update_existing_skill_preserves_body_when_omitted(self, tmp_path): def test_save_skill_writes_reference_files(self, tmp_path): """reference_files dict lands as additional files in the skill dir.""" server, reg, kb = _make_server_with_kb(tmp_path) - text = call_tool(server, "save_skill", { - "spec": {"name": "with_template", "description": "Has a template"}, - "body": "# with_template\n\nUses template.json.\n", - "reference_files": {"template.json": '{"foo": "bar"}\n'}, - }) + text = call_tool( + server, + "save_skill", + { + "spec": {"name": "with_template", "description": "Has a template"}, + "body": "# with_template\n\nUses template.json.\n", + "reference_files": {"template.json": '{"foo": "bar"}\n'}, + }, + ) assert "added" in text skill_dir = tmp_path / "runtime" / "skills" / "with_template" assert (skill_dir / "SKILL.md").exists() @@ -231,6 +281,7 @@ def test_save_skill_string_encoded_spec(self, tmp_path): # get_registry # --------------------------------------------------------------------------- + class TestGetRegistry: def test_empty_registry(self, server): @@ -253,6 +304,7 @@ def test_populated_registry(self, populated_server, populated): # search_registry # --------------------------------------------------------------------------- + class TestSearchRegistryNoKB: """search_registry with no KB configured. @@ -266,12 +318,16 @@ class TestSearchRegistryNoKB: def test_exact_name_lookup_works_without_kb(self, populated_server): """tool_name lookup is KB-free and must keep working.""" - text = call_tool(populated_server, "search_registry", {"tool_name": "tool_alpha"}) + text = call_tool( + populated_server, "search_registry", {"tool_name": "tool_alpha"} + ) assert "tool_alpha" in text def test_exact_name_miss_without_kb(self, populated_server): """tool_name with a non-existent name returns a clean 'no tool' message.""" - text = call_tool(populated_server, "search_registry", {"tool_name": "nonexistent"}) + text = call_tool( + populated_server, "search_registry", {"tool_name": "nonexistent"} + ) assert "No tool named 'nonexistent'" in text def test_query_search_without_kb_returns_helpful_error(self, populated_server): @@ -291,6 +347,7 @@ def test_empty_query_without_kb_returns_helpful_error(self, populated_server): # read_file # --------------------------------------------------------------------------- + class TestReadFile: def test_read_success(self, server, tmp_path): @@ -311,31 +368,44 @@ def test_read_missing_file(self, server): # run_command # --------------------------------------------------------------------------- + class TestRunCommand: def test_success(self, server): """Running a valid command returns its output.""" - text = call_tool(server, "run_command", { - "command": "echo", - "args": ["hello"], - }) + text = call_tool( + server, + "run_command", + { + "command": "echo", + "args": ["hello"], + }, + ) assert "hello" in text assert "Return code: 0" in text def test_command_not_found(self, server): """Running a nonexistent command returns not found error.""" - text = call_tool(server, "run_command", { - "command": "nonexistent_command_xyz", - }) + text = call_tool( + server, + "run_command", + { + "command": "nonexistent_command_xyz", + }, + ) assert "not found" in text def test_timeout(self, server): """A command that exceeds the timeout reports timeout.""" - text = call_tool(server, "run_command", { - "command": "sleep", - "args": ["30"], - "timeout": 0.1, - }) + text = call_tool( + server, + "run_command", + { + "command": "sleep", + "args": ["30"], + "timeout": 0.1, + }, + ) assert "timed out" in text @@ -343,6 +413,7 @@ def test_timeout(self, server): # save_tool_spec — dependency installation # --------------------------------------------------------------------------- + class TestSaveToolSpecDependencies: @patch("dsagt.commands.registry_server.subprocess.run") @@ -358,8 +429,15 @@ def test_deps_installed_on_save(self, mock_run, server, registry): assert "Successfully installed" in text mock_run.assert_called_once() cmd = mock_run.call_args[0][0] - assert cmd == ["uv", "pip", "install", "--python", sys.executable, - "pandas>=2.0", "numpy"] + assert cmd == [ + "uv", + "pip", + "install", + "--python", + sys.executable, + "pandas>=2.0", + "numpy", + ] @patch("dsagt.commands.registry_server.subprocess.run") def test_deps_failure_still_saves_spec(self, mock_run, server, registry): @@ -419,15 +497,19 @@ def test_uv_not_found(self, mock_run, server): # install_dependencies # --------------------------------------------------------------------------- + class TestInstallDependencies: @patch("dsagt.commands.registry_server.subprocess.run") def test_install_all(self, mock_run, tmp_path): """install_dependencies with no tool_name installs all unique deps.""" - server, reg = _make_server(tmp_path, tools=[ - make_spec("tool_a", dependencies=["pandas", "numpy"]), - make_spec("tool_b", dependencies=["numpy", "scipy"]), - ]) + server, reg = _make_server( + tmp_path, + tools=[ + make_spec("tool_a", dependencies=["pandas", "numpy"]), + make_spec("tool_b", dependencies=["numpy", "scipy"]), + ], + ) mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="") text = call_tool(server, "install_dependencies", {}) @@ -435,16 +517,27 @@ def test_install_all(self, mock_run, tmp_path): assert "tool_a" in text assert "tool_b" in text cmd = mock_run.call_args[0][0] - assert cmd == ["uv", "pip", "install", "--python", sys.executable, - "pandas", "numpy", "scipy"] + assert cmd == [ + "uv", + "pip", + "install", + "--python", + sys.executable, + "pandas", + "numpy", + "scipy", + ] @patch("dsagt.commands.registry_server.subprocess.run") def test_install_single_tool(self, mock_run, tmp_path): """install_dependencies with tool_name targets only that tool.""" - server, reg = _make_server(tmp_path, tools=[ - make_spec("tool_a", dependencies=["pandas"]), - make_spec("tool_b", dependencies=["scipy"]), - ]) + server, reg = _make_server( + tmp_path, + tools=[ + make_spec("tool_a", dependencies=["pandas"]), + make_spec("tool_b", dependencies=["scipy"]), + ], + ) mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="") text = call_tool(server, "install_dependencies", {"tool_name": "tool_b"}) @@ -471,6 +564,7 @@ def test_tools_without_deps(self, tmp_path): # KB-backed tool indexing and search # --------------------------------------------------------------------------- + def _make_server_with_kb(tmp_path, tools=None): """Create (server, registry, kb) with a real local-embedding KnowledgeBase. @@ -484,7 +578,7 @@ def _make_server_with_kb(tmp_path, tools=None): runtime_dir = tmp_path / "runtime" project_tools_dir = runtime_dir / "tools" project_tools_dir.mkdir(parents=True, exist_ok=True) - for spec in (tools or []): + for spec in tools or []: _write_tool(project_tools_dir, spec) kb = KnowledgeBase( @@ -515,10 +609,16 @@ def test_save_tool_indexes_into_kb(self, tmp_path): server, reg, kb = _make_server_with_kb(tmp_path) - call_tool(server, "save_tool_spec", {"spec": make_spec( - name="csv_filter", - description="Filter CSV rows by column value", - )}) + call_tool( + server, + "save_tool_spec", + { + "spec": make_spec( + name="csv_filter", + description="Filter CSV rows by column value", + ) + }, + ) results = kb.search("filter", collection=TOOL_REGISTRY_COLLECTION) assert len(results) > 0 @@ -542,12 +642,20 @@ def test_search_registry_by_name_not_found(self, tmp_path): def test_search_registry_semantic(self, tmp_path): """Semantic search finds tools by description similarity.""" server, reg, kb = _make_server_with_kb(tmp_path) - call_tool(server, "save_tool_spec", {"spec": make_spec( - name="csv_filter", - description="Filter and remove rows from a CSV spreadsheet based on column values", - )}) + call_tool( + server, + "save_tool_spec", + { + "spec": make_spec( + name="csv_filter", + description="Filter and remove rows from a CSV spreadsheet based on column values", + ) + }, + ) - text = call_tool(server, "search_registry", {"query": "delete rows from tabular data"}) + text = call_tool( + server, "search_registry", {"query": "delete rows from tabular data"} + ) assert "csv_filter" in text def test_search_registry_by_tag(self, tmp_path): @@ -562,7 +670,9 @@ def test_search_registry_by_tag(self, tmp_path): spec_other["tags"] = ["data_processing"] call_tool(server, "save_tool_spec", {"spec": spec_other}) - text = call_tool(server, "search_registry", {"query": "tool", "tag": "genomics"}) + text = call_tool( + server, "search_registry", {"query": "tool", "tag": "genomics"} + ) assert "fastp" in text def test_reindex_all(self, tmp_path): @@ -571,7 +681,9 @@ def test_reindex_all(self, tmp_path): server, reg, kb = _make_server_with_kb( tmp_path, - tools=[make_spec(name="preexisting", description="Already registered tool")], + tools=[ + make_spec(name="preexisting", description="Already registered tool") + ], ) # Skills were copied to runtime on init but not indexed (KB was empty) @@ -592,9 +704,12 @@ def test_no_kb_query_search_returns_explicit_error(self, tmp_path): and produced dramatically worse search results without telling anyone. """ - server, reg = _make_server(tmp_path, tools=[ - make_spec(name="csv_filter", description="Filter CSV rows"), - ]) + server, reg = _make_server( + tmp_path, + tools=[ + make_spec(name="csv_filter", description="Filter CSV rows"), + ], + ) text = call_tool(server, "search_registry", {"query": "csv"}) assert "csv_filter" not in text # the substring match must NOT happen diff --git a/tests/test_skills_catalog.py b/tests/test_skills_catalog.py new file mode 100644 index 0000000..d1006bc --- /dev/null +++ b/tests/test_skills_catalog.py @@ -0,0 +1,196 @@ +"""Unit tests for the external skill catalog (fetch / index / install) and +the native-skill mirror. No network: ``clone_github`` is monkeypatched and +the KB is a lightweight fake that records ``add_entries`` calls.""" + +import json + +import pytest + +from dsagt.agents.base import ( + _NATIVE_DESCRIPTION_CAP, + _SKILL_MANIFEST, + _mirror_skills_to, +) +from dsagt.commands import skills_catalog as sc +from dsagt.registry import CATALOG_COLLECTION_PREFIX, catalog_collection + + +def _mkskill(d, name, desc="a short description"): + d.mkdir(parents=True, exist_ok=True) + (d / "SKILL.md").write_text( + f"---\nname: {name}\ndescription: {desc}\n---\n# {name}\nbody\n" + ) + return d + + +# --------------------------------------------------------------------------- +# slug + source resolution +# --------------------------------------------------------------------------- + + +def test_repo_slug_is_collection_safe(): + slug = sc._repo_slug("https://github.com/K-Dense-AI/scientific-agent-skills") + assert slug == "k-dense-ai-scientific-agent-skills" + assert sc._repo_slug("git@github.com:Foo/Bar.git") == "foo-bar" + + +def test_resolve_source_known_url_and_shorthand(): + assert ( + sc.resolve_source("scientific")["url"] == sc.KNOWN_SOURCES["scientific"]["url"] + ) + assert ( + sc.resolve_source("https://github.com/a/b")["url"] == "https://github.com/a/b" + ) + assert sc.resolve_source("a/b")["url"] == "https://github.com/a/b" + with pytest.raises(ValueError): + sc.resolve_source("not-a-known-name") + + +# --------------------------------------------------------------------------- +# discovery +# --------------------------------------------------------------------------- + + +def test_discover_skill_dirs_flat_and_nested(tmp_path): + root = tmp_path / "skills" + _mkskill(root / "flat", "flat") + _mkskill(root / "domain" / "nested", "nested") + # A dir whose SKILL.md has no name is ignored. + bad = root / "noname" + bad.mkdir(parents=True) + (bad / "SKILL.md").write_text("---\ndescription: x\n---\nbody") + names = sorted(p.name for p in sc._discover_skill_dirs(tmp_path)) + assert names == ["flat", "nested"] + + +# --------------------------------------------------------------------------- +# find + install +# --------------------------------------------------------------------------- + + +def test_find_catalog_skill_and_ambiguity(tmp_path): + cache = tmp_path / "cache" + _mkskill(cache / "srcA" / "skills" / "alpha", "alpha") + found = sc.find_catalog_skill("alpha", cache_dir=cache) + assert found.name == "alpha" + with pytest.raises(LookupError): + sc.find_catalog_skill("missing", cache_dir=cache) + # Same skill name in a second source → ambiguous. + _mkskill(cache / "srcB" / "skills" / "alpha", "alpha") + with pytest.raises(LookupError): + sc.find_catalog_skill("alpha", cache_dir=cache) + + +def test_install_into_project_copies_subdirs(tmp_path): + cache = tmp_path / "cache" + skill = _mkskill(cache / "src" / "vasp-to-isaac", "vasp-to-isaac") + (skill / "scripts").mkdir() + (skill / "scripts" / "run.py").write_text("print(1)") + (skill / "references").mkdir() + (skill / "references" / "spec.md").write_text("# spec") + + proj = tmp_path / "proj" + proj.mkdir() + info = sc.install_into_project("vasp-to-isaac", proj, cache_dir=cache) + dest = proj / "skills" / "vasp-to-isaac" + assert info["action"] == "added" + assert (dest / "SKILL.md").exists() + assert (dest / "scripts" / "run.py").exists() + assert (dest / "references" / "spec.md").exists() + # Re-install reports "updated". + assert ( + sc.install_into_project("vasp-to-isaac", proj, cache_dir=cache)["action"] + == "updated" + ) + + +# --------------------------------------------------------------------------- +# sync_source (mocked clone + fake KB) +# --------------------------------------------------------------------------- + + +class _FakeKB: + def __init__(self, index_dir): + self.index_dir = index_dir + self.collections = [] + self.adds = [] # (collection, metadatas) + + def add_entries(self, texts, collection, metadatas=None): + self.adds.append((collection, metadatas)) + if collection not in self.collections: + self.collections.append(collection) + return {"collection": collection, "entries_added": len(texts)} + + +def test_sync_source_indexes_per_source_collection(tmp_path, monkeypatch): + # Fake clone: populate dest/ with two skills. + def fake_clone(url, dest, branch="main", include=None): + sub = include[0] if include else "" + base = dest / sub if sub else dest + _mkskill(base / "s1", "s1") + _mkskill(base / "s2", "s2") + + monkeypatch.setattr("dsagt.commands.setup_core_kb.clone_github", fake_clone) + + kb = _FakeKB(tmp_path / "kb_index") + cache = tmp_path / "cache" + stats = sc.sync_source( + {"url": "https://github.com/x/y", "branch": "main", "subdir": "skills"}, + kb=kb, + cache_dir=cache, + ) + slug = sc._repo_slug("https://github.com/x/y") + coll = catalog_collection(slug) + assert stats["discovered"] == 2 and stats["indexed"] == 2 + assert coll.startswith(CATALOG_COLLECTION_PREFIX) + added_coll, metas = kb.adds[-1] + assert added_coll == coll + assert all(m["source"] == f"catalog:{slug}" for m in metas) + assert {m["skill_name"] for m in metas} == {"s1", "s2"} + + +# --------------------------------------------------------------------------- +# native mirror +# --------------------------------------------------------------------------- + + +def test_mirror_manifest_preserves_user_skills_and_reaps(tmp_path): + target = tmp_path / ".claude" / "skills" + target.mkdir(parents=True) + # A user-authored skill dsagt must never touch. + _mkskill(target / "user-skill", "user-skill") + + bundled = _mkskill(tmp_path / "bundled" / "skill-creator", "skill-creator") + proj = _mkskill(tmp_path / "proj" / "alpha", "alpha") + + _mirror_skills_to(target, [bundled, proj]) + assert sorted(p.name for p in target.iterdir() if p.is_dir()) == [ + "alpha", + "skill-creator", + "user-skill", + ] + manifest = json.loads((target / _SKILL_MANIFEST).read_text()) + assert manifest == ["alpha", "skill-creator"] + assert "user-skill" not in manifest + + # Re-run with skill-creator gone → reaped; user-skill preserved. + _mirror_skills_to(target, [proj]) + assert sorted(p.name for p in target.iterdir() if p.is_dir()) == [ + "alpha", + "user-skill", + ] + + +def test_mirror_truncates_long_description(tmp_path): + long_desc = "x" * (_NATIVE_DESCRIPTION_CAP + 500) + src = _mkskill(tmp_path / "src" / "big", "big", desc=long_desc) + target = tmp_path / ".claude" / "skills" + _mirror_skills_to(target, [src]) + + import yaml + + mirrored = (target / "big" / "SKILL.md").read_text() + front = yaml.safe_load(mirrored.split("---", 2)[1]) + assert len(front["description"]) <= _NATIVE_DESCRIPTION_CAP + # Source untouched. + assert len((src / "SKILL.md").read_text()) > _NATIVE_DESCRIPTION_CAP diff --git a/use_cases/isaac_skills_demo/README.md b/use_cases/isaac_skills_demo/README.md new file mode 100644 index 0000000..200de44 --- /dev/null +++ b/use_cases/isaac_skills_demo/README.md @@ -0,0 +1,159 @@ +# DSAgt Demo: Skill-Driven VASP → ISAAC Conversion + +A lightweight mock of the [`isaac_vasp`](../isaac_vasp/) workflow, built specifically to **vet the skill-management feature**. Instead of shipping a hand-written converter skill, this walkthrough has the agent **discover, install, and author skills** — and surfaces them through Claude Code's *native* skill discovery. + +It uses tiny **mock VASP outputs** (`mock_data/`, a few KB) so the whole thing runs in seconds with no DFT, no NERSC, and no 32 MB OUTCAR files. + +## What this demonstrates (the new functionality) + +- **External skill catalog** — pull Agent-Skills from GitHub repos (default: K-Dense `scientific-agent-skills`, 140+ skills) into a searchable catalog that is **not** loaded into the agent's context. +- **`search_skills`** spanning installed skills *and* the catalog (catalog hits marked `[catalog]`). +- **`install_skill`** — move a chosen catalog skill into the project, then into Claude's native `.claude/skills/`. +- **`add_skill_source`** — the agent enables another source (e.g. `anthropic`) via an MCP tool call. +- **`skill-creator`** — the bundled meta-skill scaffolds a brand-new `vasp-to-isaac-mock` skill from the Anthropic template. +- **Native mirror** — installed + bundled skills appear under `.claude/skills//` (tracked by `.dsagt-managed.json`), so Claude auto-invokes them with no MCP round-trip. + +The two tiers in one sentence: **catalog = searchable but not in context; installed = native and auto-invoked.** + +## Prerequisites + +- DSAgt installed (`uv sync --all-groups`) +- Claude Code installed (`npm i -g @anthropic-ai/claude-code`) +- Valid embedding credentials (so `search_skills` works) — `EMBEDDING_*` in your shell or project config +- Git installed (catalog sync uses a shallow clone) + +## Setup + +### 1. Build the core KB + default skill catalog + +```bash +dsagt setup-kb +``` + +This indexes the bundled tools/skills **and** clones + indexes the default skill source (K-Dense scientific). To skip the catalog, pass `--no-skill-catalog`. To go faster, you can defer the catalog and add it per-project later (Step A). + +### 2. Initialize a project + +```bash +dsagt init isaac-skills-demo --agent claude +``` + +The generated `dsagt_config.yaml` already carries a `skills:` block with the `scientific` source enabled and `populate_native: true`. + +### 3. Start the session + +```bash +dsagt start isaac-skills-demo +``` + +`dsagt start` mirrors installed + bundled skills into `.claude/skills/` **before** launching Claude, so the bundled `skill-creator` is already discoverable. Copy the mock data into the project first so the agent can reach it: + +```bash +cp -r use_cases/isaac_skills_demo/mock_data ~/dsagt-projects/isaac-skills-demo/mock_data +``` + +## Execution + +### A. Browse and install a skill from the catalog + +First confirm the catalog is searchable (these are NOT in Claude's context — they live in the KB): + +```bash +dsagt skills list isaac-skills-demo --catalog +``` + +You should see a `skills_catalog__k-dense-ai-scientific-agent-skills` collection. Now have the agent search it: + +```text +Search the skill catalog for a skill that helps work with VASP, pymatgen, or DFT materials data. List what you find and which are installable from the catalog. +``` + +The agent calls `search_skills(...)`; catalog hits are marked `[catalog · install_skill to add]`. Install one: + +```text +Install the most relevant materials/DFT skill you found from the catalog into this project. +``` + +The agent calls `install_skill(skill_name=...)`. + +**Verify:** + +```bash +ls ~/dsagt-projects/isaac-skills-demo/skills/ +``` + +The installed skill directory (with any `scripts/` and `references/`) is now under the project's `skills/`. + +### B. Add a second catalog source via the agent + +```text +Enable the "anthropic" skill source so we also have the official Anthropic skills available, then tell me how many skills that added to the catalog. +``` + +The agent calls `add_skill_source(source="anthropic")` — it clones + indexes that repo into its own catalog collection and persists the source to `dsagt_config.yaml`. Confirm: + +```text +List the skill sources currently configured and synced. +``` + +(`list_skill_sources`.) + +### C. Author the project-specific skill with `skill-creator` + +The real `isaac_vasp` ships a hand-written `vasp-to-isaac` converter. Here we let the agent build a mock one using the bundled meta-skill: + +```text +Use the skill-creator skill to author a new project skill named "vasp-to-isaac-mock". It should: read a mock VASP calculation directory (POSCAR + INCAR + OUTCAR) under mock_data/mock_slab/, extract the final energy, atom count, and whether it's a slab relaxation (NSW > 0), and emit a small ISAAC-style JSON record. Use mock_data/expected_isaac_record.json as the shape to target. Save it with save_skill. +``` + +The agent reads `skill-creator`'s template (`references/SKILL_template.md`) and spec, then writes `/skills/vasp-to-isaac-mock/`. + +**Verify:** + +```bash +cat ~/dsagt-projects/isaac-skills-demo/skills/vasp-to-isaac-mock/SKILL.md +``` + +### D. Run the new skill on the mock data + +```text +Invoke the vasp-to-isaac-mock workflow on mock_data/mock_slab/ and write the result to audit/mock_slab_isaac.json. Then diff its structure against mock_data/expected_isaac_record.json and report any missing fields. +``` + +### E. Inspect both tiers + +```bash +# Installed (native-discoverable) skills — bundled + project + anything installed +dsagt skills list isaac-skills-demo + +# The native mirror Claude actually reads, plus the dsagt-managed manifest +ls ~/dsagt-projects/isaac-skills-demo/.claude/skills/ +cat ~/dsagt-projects/isaac-skills-demo/.claude/skills/.dsagt-managed.json +``` + +The manifest lists only the skills **dsagt** placed (`skill-creator`, the installed catalog skill, `vasp-to-isaac-mock`). Any skill you hand-create under `.claude/skills/` is never touched. + +To pick up newly-mirrored skills as native `/commands`, restart Claude (`dsagt start isaac-skills-demo` again, then relaunch). + +## Post-Conditions + +1. The KB holds per-source catalog collections (`skills_catalog__*`) for `scientific` (+ `anthropic` after Step B), searchable via `search_skills` but absent from Claude's context. +2. A catalog skill was installed into `/skills/` and mirrored into `.claude/skills/`. +3. A new `vasp-to-isaac-mock` skill, authored via `skill-creator`, exists and is native-discoverable. +4. `audit/mock_slab_isaac.json` was produced from the mock VASP directory and matches the ISAAC shape. +5. `.claude/skills/.dsagt-managed.json` tracks exactly the dsagt-placed skills. + +## Cleanup + +```bash +dsagt stop isaac-skills-demo +dsagt rm isaac-skills-demo # add -y to skip the prompt +``` + +The shared catalog cache lives at `~/dsagt-projects/.skill_sources/` and is reused across projects; delete it to force a fresh clone on the next `setup-kb` / `add_skill_source`. + +## Notes + +- `mock_data/` is intentionally tiny and **not** real DFT output — the OUTCAR is a truncated stub. It exists only to exercise the conversion skill's parse-and-emit path. +- If your embedding backend isn't configured, `search_skills` degrades to the "requires a configured knowledge base" message; `install_skill` and the native mirror still work (they're pure filesystem operations). +- Swap in other catalogs the same way: `dsagt skills add isaac-skills-demo antigravity` (or `composio`, or any `https://github.com/owner/repo`). diff --git a/use_cases/isaac_skills_demo/mock_data/expected_isaac_record.json b/use_cases/isaac_skills_demo/mock_data/expected_isaac_record.json new file mode 100644 index 0000000..5d03192 --- /dev/null +++ b/use_cases/isaac_skills_demo/mock_data/expected_isaac_record.json @@ -0,0 +1,63 @@ +{ + "isaac_record_version": "1.05", + "record_id": "mock-iro2-110-slab-0001", + "record_type": "dft_calculation", + "record_domain": "materials", + "source_type": "MOCK", + "_note": "Reference SHAPE/values for the isaac_skills_demo. Derived from the tiny mock_slab/ stub, NOT a real DFT run.", + "timestamps": { + "created_utc": "2026-06-01T10:00:00Z" + }, + "sample": { + "material": { + "name": "IrO2 (110) slab", + "formula": "IrO2", + "provenance": "mock" + }, + "sample_form": "surface_slab" + }, + "system": { + "domain": "materials", + "technique": "DFT", + "instrument": { + "instrument_type": "compute", + "instrument_name": "VASP", + "vendor_or_project": "VASP" + }, + "configuration": { + "code_version": "6.3.2", + "compute_architecture": "cpu", + "cores": null + } + }, + "computation": { + "method": { + "family": "DFT", + "functional_class": "GGA", + "functional_name": "PBE", + "pseudopotential": "PAW", + "cutoff_eV": 520.0, + "spin_treatment": "collinear", + "hubbard_u": {"Ir": 4.0} + }, + "relaxation": { + "is_relaxation": true, + "nsw": 50, + "converged": true, + "ionic_steps": 50 + } + }, + "results": { + "total_energy_eV": -132.84210000, + "energy_sigma0_eV": -132.84210000, + "n_atoms": 12, + "n_species": {"Ir": 4, "O": 8}, + "total_magnetization_muB": 8.0123, + "max_residual_force_eV_per_A": 0.011 + }, + "assets": [ + {"name": "POSCAR", "role": "structure_input"}, + {"name": "INCAR", "role": "calc_parameters"}, + {"name": "OUTCAR", "role": "calc_output"} + ] +} diff --git a/use_cases/isaac_skills_demo/mock_data/mock_slab/INCAR b/use_cases/isaac_skills_demo/mock_data/mock_slab/INCAR new file mode 100644 index 0000000..4c80632 --- /dev/null +++ b/use_cases/isaac_skills_demo/mock_data/mock_slab/INCAR @@ -0,0 +1,18 @@ +# MOCK INCAR — slab ionic relaxation (NSW > 0 marks this as a slab calc) +SYSTEM = IrO2(110) mock slab +ISTART = 0 +ICHARG = 2 +ENCUT = 520 +ISMEAR = 0 +SIGMA = 0.05 +IBRION = 2 +NSW = 50 +ISIF = 2 +EDIFF = 1E-5 +EDIFFG = -0.02 +ISPIN = 2 +LDAU = .TRUE. +LDAUTYPE = 2 +LDAUL = 2 -1 +LDAUU = 4.0 0.0 +GGA = PE diff --git a/use_cases/isaac_skills_demo/mock_data/mock_slab/OUTCAR b/use_cases/isaac_skills_demo/mock_data/mock_slab/OUTCAR new file mode 100644 index 0000000..4640af5 --- /dev/null +++ b/use_cases/isaac_skills_demo/mock_data/mock_slab/OUTCAR @@ -0,0 +1,42 @@ + MOCK OUTCAR — heavily truncated stub for the skills demo. NOT real VASP output. + Only the few lines a converter typically greps for are kept; the SCF/eigenvalue + blocks that make a real OUTCAR ~250k lines are omitted on purpose. + + vasp.6.3.2 mock build + executed on LinuxIFC date 2026.06.01 10:00:00 + + INCAR: + ENCUT = 520.0 + ISPIN = 2 + NSW = 50 + LDAUU = 4.000 0.000 + + energy without entropy= -123.45678901 energy(sigma->0) = -123.40000000 + ... + FREE ENERGIE OF THE ION-ELECTRON SYSTEM (eV) + --------------------------------------------------- + free energy TOTEN = -132.10000000 eV (ionic step 1) + + energy without entropy= -131.98000000 energy(sigma->0) = -131.99000000 + + FREE ENERGIE OF THE ION-ELECTRON SYSTEM (eV) + --------------------------------------------------- + free energy TOTEN = -132.84210000 eV (ionic step 50, converged) + + energy without entropy= -132.80000000 energy(sigma->0) = -132.84210000 + + POSITION TOTAL-FORCE (eV/Angst) + ----------------------------------------------------------------------------------- + 0.00000 0.00000 7.04000 0.000000 0.000000 -0.004000 + 3.19250 3.24900 7.04000 0.000000 0.000000 0.003000 + 0.00000 0.00000 9.90000 0.000000 0.000000 0.011000 + ----------------------------------------------------------------------------------- + + magnetization (x) + number of electron 192.0000000 magnetization 8.0123000 + + General timing and accounting informations for this job: + ======================================================== + Total CPU time used (sec): 4210.123 + Elapsed time (sec): 1130.456 + reached required accuracy - stopping structural energy minimisation diff --git a/use_cases/isaac_skills_demo/mock_data/mock_slab/POSCAR b/use_cases/isaac_skills_demo/mock_data/mock_slab/POSCAR new file mode 100644 index 0000000..5101962 --- /dev/null +++ b/use_cases/isaac_skills_demo/mock_data/mock_slab/POSCAR @@ -0,0 +1,21 @@ +IrO2 (110) mock slab [MOCK — not real DFT input] +1.0 + 6.3850000000 0.0000000000 0.0000000000 + 0.0000000000 6.4980000000 0.0000000000 + 0.0000000000 0.0000000000 22.0000000000 + Ir O + 4 8 +Selective dynamics +Direct + 0.0000 0.0000 0.3200 F F F + 0.5000 0.5000 0.3200 F F F + 0.0000 0.0000 0.4500 T T T + 0.5000 0.5000 0.4500 T T T + 0.2500 0.2500 0.3850 T T T + 0.7500 0.7500 0.3850 T T T + 0.2500 0.7500 0.3850 T T T + 0.7500 0.2500 0.3850 T T T + 0.2500 0.2500 0.5100 T T T + 0.7500 0.7500 0.5100 T T T + 0.2500 0.7500 0.5100 T T T + 0.7500 0.2500 0.5100 T T T From b6e559803d1fcbbca9e79f8c46b7c2b6ac6fb483 Mon Sep 17 00:00:00 2001 From: aarontuor Date: Fri, 12 Jun 2026 10:54:34 -0700 Subject: [PATCH 02/44] fix(skills): persist CLI-added sources to config; demo prompt script First-pass vetting of use_cases/isaac_skills_demo against the real K-Dense catalog surfaced one bug: `dsagt skills add ` synced + indexed the catalog but never wrote the source into dsagt_config.yaml, so a later config-driven `dsagt skills sync` would forget it. Only the `add_skill_source` MCP tool persisted. - Move the persist logic into a shared `persist_source_to_config` helper in skills_catalog.py; call it from both the CLI add-source path and the knowledge-server `add_skill_source` handler (removes the duplicated `_persist_skill_source`). - Regression test for the helper (append + dedupe + missing-config no-op). - Add use_cases/isaac_skills_demo/PROMPTS.md: the 8-prompt hand-pass script plus first-pass results (init/mirror, 146-skill sync, search, install pymatgen, native re-mirror, add anthropic all verified). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/dsagt/commands/cli.py | 8 +++ src/dsagt/commands/knowledge_server.py | 28 ++------ src/dsagt/commands/skills_catalog.py | 25 +++++++ tests/test_skills_catalog.py | 16 +++++ use_cases/isaac_skills_demo/PROMPTS.md | 95 ++++++++++++++++++++++++++ 5 files changed, 151 insertions(+), 21 deletions(-) create mode 100644 use_cases/isaac_skills_demo/PROMPTS.md diff --git a/src/dsagt/commands/cli.py b/src/dsagt/commands/cli.py index f935846..0e0b59c 100644 --- a/src/dsagt/commands/cli.py +++ b/src/dsagt/commands/cli.py @@ -387,6 +387,8 @@ def _cmd_skills(args): from dsagt.commands.skills_catalog import ( KNOWN_SOURCES, install_into_project, + persist_source_to_config, + resolve_source, sync_source, ) from dsagt.registry import ( @@ -434,11 +436,17 @@ def _cmd_skills(args): or target.count("/") == 1 ) if is_source: + spec = resolve_source(target) + if target in KNOWN_SOURCES: + spec.setdefault("name", target) kb = kb_from_config(config) try: stats = sync_source(target, kb=kb) finally: kb.close() + persist_source_to_config( + pdir, {"name": spec.get("name", stats["slug"]), **spec} + ) print(f"Added source {stats['url']}: {stats['indexed']} skill(s) indexed.") print( "Run 'dsagt start' to mirror an installed skill natively, or " diff --git a/src/dsagt/commands/knowledge_server.py b/src/dsagt/commands/knowledge_server.py index a06716c..92643b6 100644 --- a/src/dsagt/commands/knowledge_server.py +++ b/src/dsagt/commands/knowledge_server.py @@ -569,25 +569,6 @@ async def _handle_kb_dismiss_suggestion( # --------------------------------------------------------------------------- -def _persist_skill_source(runtime_dir: Path, spec: dict) -> None: - """Append a resolved source to ``skills.sources`` in the project config. - - Dedupes by URL. No-op if the config file is missing (e.g. tests with a - bare runtime dir) — the catalog is still indexed either way. - """ - cfg_path = runtime_dir / "dsagt_config.yaml" - if not cfg_path.exists(): - return - cfg = yaml.safe_load(cfg_path.read_text()) or {} - skills = cfg.setdefault("skills", {}) - sources = skills.setdefault("sources", []) - if not any(s.get("url") == spec.get("url") for s in sources): - sources.append( - {k: spec[k] for k in ("name", "url", "branch", "subdir") if k in spec} - ) - cfg_path.write_text(yaml.dump(cfg, default_flow_style=False, sort_keys=False)) - - async def _handle_add_skill_source( arguments: dict, *, @@ -595,7 +576,12 @@ async def _handle_add_skill_source( runtime_dir: Path, ) -> dict: """Enable a skill source (known name or GitHub URL): clone + index the catalog.""" - from dsagt.commands.skills_catalog import KNOWN_SOURCES, resolve_source, sync_source + from dsagt.commands.skills_catalog import ( + KNOWN_SOURCES, + persist_source_to_config, + resolve_source, + sync_source, + ) source = arguments.get("source") if not source: @@ -609,7 +595,7 @@ async def _handle_add_skill_source( stats = await asyncio.to_thread(sync_source, source, kb=kb) except (ValueError, RuntimeError) as e: return {"error": str(e)} - _persist_skill_source( + persist_source_to_config( runtime_dir, {"name": spec.get("name", stats["slug"]), **spec} ) return { diff --git a/src/dsagt/commands/skills_catalog.py b/src/dsagt/commands/skills_catalog.py index c375a3b..e59c083 100644 --- a/src/dsagt/commands/skills_catalog.py +++ b/src/dsagt/commands/skills_catalog.py @@ -98,6 +98,31 @@ def resolve_source(source: str | dict) -> dict: ) +def persist_source_to_config(project_dir: str | Path, spec: dict) -> bool: + """Append a resolved source to ``skills.sources`` in the project config. + + Dedupes by URL. Returns True if the config was updated. No-op (returns + False) if the config file is missing — the catalog is still indexed + either way. Used by both the ``add_skill_source`` MCP tool and the + ``dsagt skills add`` CLI so a CLI-added source is re-synced by a later + config-driven ``dsagt skills sync``. + """ + import yaml + + cfg_path = Path(project_dir) / "dsagt_config.yaml" + if not cfg_path.exists(): + return False + cfg = yaml.safe_load(cfg_path.read_text()) or {} + sources = cfg.setdefault("skills", {}).setdefault("sources", []) + if any(s.get("url") == spec.get("url") for s in sources): + return False + sources.append( + {k: spec[k] for k in ("name", "url", "branch", "subdir") if k in spec} + ) + cfg_path.write_text(yaml.dump(cfg, default_flow_style=False, sort_keys=False)) + return True + + def _repo_slug(url: str) -> str: """Stable, collection-name-safe slug from a GitHub URL (``owner-repo``).""" s = url.rstrip("/") diff --git a/tests/test_skills_catalog.py b/tests/test_skills_catalog.py index d1006bc..277313a 100644 --- a/tests/test_skills_catalog.py +++ b/tests/test_skills_catalog.py @@ -34,6 +34,22 @@ def test_repo_slug_is_collection_safe(): assert sc._repo_slug("git@github.com:Foo/Bar.git") == "foo-bar" +def test_persist_source_to_config_appends_and_dedupes(tmp_path): + import yaml + + cfg = tmp_path / "dsagt_config.yaml" + cfg.write_text(yaml.dump({"project": "p", "skills": {"sources": []}})) + spec = {"name": "anthropic", "url": "https://github.com/anthropics/skills", "branch": "main"} + assert sc.persist_source_to_config(tmp_path, spec) is True + sources = yaml.safe_load(cfg.read_text())["skills"]["sources"] + assert sources[-1]["name"] == "anthropic" + # Idempotent: same URL is not appended twice. + assert sc.persist_source_to_config(tmp_path, spec) is False + assert len(yaml.safe_load(cfg.read_text())["skills"]["sources"]) == 1 + # No config file → no-op, no crash. + assert sc.persist_source_to_config(tmp_path / "nope", spec) is False + + def test_resolve_source_known_url_and_shorthand(): assert ( sc.resolve_source("scientific")["url"] == sc.KNOWN_SOURCES["scientific"]["url"] diff --git a/use_cases/isaac_skills_demo/PROMPTS.md b/use_cases/isaac_skills_demo/PROMPTS.md new file mode 100644 index 0000000..b154be0 --- /dev/null +++ b/use_cases/isaac_skills_demo/PROMPTS.md @@ -0,0 +1,95 @@ +# Hand-pass prompt script — isaac_skills_demo + +The deterministic backbone (init, catalog sync, CLI `skills` commands, the +native mirror) was already vetted by a first pass — see "First-pass results" +at the bottom. This script is for the **interactive agent pass**: paste each +prompt into Claude Code (running inside the project) and check the expected +behavior. + +## Before you start + +The project `isaac-skills-demo` is already set up from the first pass: +- catalog synced (146 K-Dense + 17 Anthropic skills), +- `pymatgen` already installed and mirrored into `.claude/skills/`, +- `mock_data/` copied into the project. + +To start fresh instead, delete and rebuild: + +```bash +dsagt rm isaac-skills-demo -y +dsagt init isaac-skills-demo --agent claude +cp -r use_cases/isaac_skills_demo/mock_data ~/dsagt-projects/isaac-skills-demo/mock_data +``` + +Launch the agent: + +```bash +dsagt start isaac-skills-demo +``` + +--- + +## Prompts (paste one at a time) + +### 1 — Confirm native discovery of the bundled meta-skill +> What skills do you have available right now? List them and say which are dsagt-managed. + +*Expect:* `skill-creator`, `datacard-generator`, and (if you didn't rebuild) `pymatgen` are visible as native skills. + +### 2 — Search the catalog (NOT in context) +> Search the skill catalog for a skill that helps work with VASP, pymatgen, or DFT materials data. List what you find and which are installable from the catalog. + +*Expect:* `search_skills` is called; catalog hits are tagged `[catalog · install_skill to add]`; `pymatgen` ranks at/near the top. + +### 3 — Install from the catalog +> Install the most relevant materials/DFT skill you found from the catalog into this project. + +*Expect:* `install_skill(skill_name="pymatgen")`; reply notes it'll be native after the next start. (Already installed if you didn't rebuild — it should say "updated".) + +### 4 — Add a second catalog source via MCP +> Enable the "anthropic" skill source so we also have the official Anthropic skills available, then tell me how many skills that added to the catalog. + +*Expect:* `add_skill_source(source="anthropic")` → ~17 skills indexed; the source is written into `dsagt_config.yaml`. + +### 5 — List configured/synced sources +> List the skill sources currently configured and synced. + +*Expect:* `list_skill_sources` → `scientific` + `anthropic` known/synced. + +### 6 — Author a project skill with skill-creator +> Use the skill-creator skill to author a new project skill named "vasp-to-isaac-mock". It should: read a mock VASP calculation directory (POSCAR + INCAR + OUTCAR) under mock_data/mock_slab/, extract the final energy, atom count, and whether it's a slab relaxation (NSW > 0), and emit a small ISAAC-style JSON record. Use mock_data/expected_isaac_record.json as the shape to target. Save it with save_skill. + +*Expect:* the agent reads skill-creator's template + spec, then `save_skill` writes `/skills/vasp-to-isaac-mock/`. + +### 7 — Run the new skill on the mock data +> Invoke the vasp-to-isaac-mock workflow on mock_data/mock_slab/ and write the result to audit/mock_slab_isaac.json. Then diff its structure against mock_data/expected_isaac_record.json and report any missing fields. + +*Expect:* a produced `audit/mock_slab_isaac.json` with the key fields (final energy ≈ -132.8421 eV, 12 atoms, slab/NSW=50). Compare to the reference. + +### 8 — Inspect both tiers (run in a shell, not the agent) +```bash +dsagt skills list isaac-skills-demo +dsagt skills list isaac-skills-demo --catalog +ls ~/dsagt-projects/isaac-skills-demo/.claude/skills/ +cat ~/dsagt-projects/isaac-skills-demo/.claude/skills/.dsagt-managed.json +``` + +*Expect:* installed list includes `pymatgen` + `vasp-to-isaac-mock`; catalog lists both `skills_catalog__*` collections; the manifest tracks exactly the dsagt-placed skills (your hand-authored `.claude/skills/` entries, if any, are untouched). + +--- + +## First-pass results (already verified, no agent needed) + +| Step | Result | +|---|---| +| `dsagt init` | ✅ `.claude/skills/` mirror fired at init: `skill-creator` (+ refs) + `datacard-generator`; manifest correct; config `skills:` block present | +| `dsagt skills sync` | ✅ real clone of K-Dense, **146 skills** indexed into `skills_catalog__k-dense-ai-scientific-agent-skills` (~19s, local embeddings) | +| `dsagt skills list --catalog` | ✅ shows the catalog collection | +| `dsagt skills search "VASP pymatgen DFT materials"` | ✅ `pymatgen` top catalog hit; tiers tagged `[bundled]` / `[catalog:…]` | +| `dsagt skills add … pymatgen` | ✅ installed into `skills/pymatgen/` **with** `scripts/` + `references/` | +| start-equivalent re-mirror | ✅ `pymatgen` now in `.claude/skills/` + manifest | +| `dsagt skills add … anthropic` | ✅ second source cloned + **17 skills** indexed; source persisted to config | + +**Caveat to know:** with the default **local** embedding backend (`bge-small`), absolute search scores are low (~0.03) because short queries under-score long SKILL.md texts — *ranking* is still correct (pymatgen #1). An API embedding model scores higher. Set `EMBEDDING_*` / switch `embedding.backend` to `api` for sharper relevance. + +**Fix applied during the first pass:** the CLI `dsagt skills add ` path now also persists the source to `dsagt_config.yaml` (previously only the MCP `add_skill_source` tool did), so a later config-driven `dsagt skills sync` re-syncs it. Regression test added. From a049992e40ca9876aba961bc74baecd8c8ebb18f Mon Sep 17 00:00:00 2001 From: aarontuor Date: Fri, 12 Jun 2026 12:24:27 -0700 Subject: [PATCH 03/44] docs(skills): demo rebuild block must sync the catalog The isaac_skills_demo rebuild path (rm + init) left the external catalog empty because a fresh `init` copies only the shared KB, while the catalog is project-scoped. The agent's search_skills then correctly returned no catalog hits. Add the required `dsagt skills sync` step to the rebuild block, a pre-launch catalog check, and a note on the global setup-kb alternative. Co-Authored-By: Claude Opus 4.8 (1M context) --- use_cases/isaac_skills_demo/PROMPTS.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/use_cases/isaac_skills_demo/PROMPTS.md b/use_cases/isaac_skills_demo/PROMPTS.md index b154be0..cf29800 100644 --- a/use_cases/isaac_skills_demo/PROMPTS.md +++ b/use_cases/isaac_skills_demo/PROMPTS.md @@ -13,12 +13,26 @@ The project `isaac-skills-demo` is already set up from the first pass: - `pymatgen` already installed and mirrored into `.claude/skills/`, - `mock_data/` copied into the project. -To start fresh instead, delete and rebuild: +To start fresh instead, delete and rebuild — **including the catalog sync** +(a fresh `init` only copies the *shared* KB; the external catalog is +project-scoped, so it must be synced after init or the catalog will be empty +and prompt 2 finds nothing): ```bash dsagt rm isaac-skills-demo -y dsagt init isaac-skills-demo --agent claude cp -r use_cases/isaac_skills_demo/mock_data ~/dsagt-projects/isaac-skills-demo/mock_data +dsagt skills sync isaac-skills-demo # REQUIRED: populate the catalog (146 skills) +``` + +> Alternatively, run the global `dsagt setup-kb` once (it syncs the default +> catalog into the *shared* KB, which every new `init` then copies in). The +> per-project `dsagt skills sync` above is the lighter, self-contained path. + +Confirm the catalog is present before launching: + +```bash +dsagt skills list isaac-skills-demo --catalog # expect a skills_catalog__* collection ``` Launch the agent: From 752b95953aeb266214aae03c82dbf6f6fb6fb235 Mon Sep 17 00:00:00 2001 From: aarontuor Date: Tue, 23 Jun 2026 13:40:56 -0700 Subject: [PATCH 04/44] feat(skills): clarify catalog discovery and install signals An empty/unsynced external skill catalog was indistinguishable from a genuine no-match, forcing the agent through a multi-step discovery dance and a misleading "skill unusable until restart" message. - search_skills: when no catalog is synced, say so and point at list_skill_sources / add_skill_source instead of a bare no-match - list_skill_sources: flag each known source synced/available with its indexed count, rather than two parallel lists to cross-reference - install_skill: state the skill is usable this session immediately; restart only enables hands-free native auto-invocation - dsagt_instructions: document the catalog as opt-in and the list_skill_sources -> add_skill_source -> search_skills -> install flow Co-Authored-By: Claude Opus 4.8 (1M context) --- src/dsagt/commands/knowledge_server.py | 57 ++++++++++++++++++++++---- src/dsagt/commands/registry_server.py | 27 +++++++++--- src/dsagt/dsagt_instructions.md | 11 ++++- tests/test_knowledge_server.py | 11 +++-- tests/test_registry_server.py | 10 +++++ 5 files changed, 96 insertions(+), 20 deletions(-) diff --git a/src/dsagt/commands/knowledge_server.py b/src/dsagt/commands/knowledge_server.py index 92643b6..2bae002 100644 --- a/src/dsagt/commands/knowledge_server.py +++ b/src/dsagt/commands/knowledge_server.py @@ -607,18 +607,57 @@ async def _handle_add_skill_source( async def _handle_list_skill_sources(arguments: dict, *, kb: KnowledgeBase) -> dict: - """List known + synced skill sources and their indexed counts.""" - from dsagt.commands.skills_catalog import KNOWN_SOURCES - from dsagt.registry import CATALOG_COLLECTION_PREFIX + """List known skill sources, each flagged synced/available with its count. + + A source is ``synced`` (searchable via ``search_skills``) only after an + ``add_skill_source`` call has cloned + indexed it; otherwise it is + ``available`` (known name + URL, nothing indexed yet). Reporting the + flag + ``indexed`` count inline means the agent doesn't have to cross- + reference a separate ``synced_collections`` list to tell the difference. + """ + import json + + from dsagt.commands.skills_catalog import KNOWN_SOURCES, _repo_slug + from dsagt.registry import CATALOG_COLLECTION_PREFIX, catalog_collection synced = {c for c in kb.collections if c.startswith(CATALOG_COLLECTION_PREFIX)} + + def _indexed_count(collection: str) -> int: + ids = Path(kb.index_dir) / collection / "chroma_ids.json" + try: + return len(json.loads(ids.read_text())) + except (FileNotFoundError, ValueError): + return 0 + + sources = {} + for name, s in KNOWN_SOURCES.items(): + coll = catalog_collection(_repo_slug(s["url"])) + is_synced = coll in synced + sources[name] = { + "url": s["url"], + "description": s.get("description", ""), + "synced": is_synced, + "indexed": _indexed_count(coll) if is_synced else 0, + } + + # Surface any synced catalog whose source isn't in KNOWN_SOURCES (added + # by raw GitHub URL) so the count is never silently dropped. + known_colls = { + catalog_collection(_repo_slug(s["url"])) for s in KNOWN_SOURCES.values() + } + extra = sorted(synced - known_colls) + + any_synced = any(v["synced"] for v in sources.values()) or bool(extra) return { - "known_sources": { - name: {"url": s["url"], "description": s.get("description", "")} - for name, s in KNOWN_SOURCES.items() - }, - "synced_collections": sorted(synced), - "note": "add_skill_source to enable; search_skills to browse.", + "sources": sources, + "other_synced_collections": extra, + "note": ( + "add_skill_source to sync a source whose synced=false; " + "then search_skills to browse. search_skills only sees synced sources." + if any_synced + else "No catalog synced yet — add_skill_source " + "(e.g. 'scientific') to enable one, then search_skills to browse." + ), } diff --git a/src/dsagt/commands/registry_server.py b/src/dsagt/commands/registry_server.py index 3d7fd80..5a57046 100644 --- a/src/dsagt/commands/registry_server.py +++ b/src/dsagt/commands/registry_server.py @@ -333,9 +333,10 @@ async def _handle_search_skills( # ``skills_catalog__`` collection, then merge by score. Installed # skills are also natively discovered by the agent; the catalog is the # part native discovery can't do (it isn't loaded into context). - collections = [SKILLS_COLLECTION] + [ + catalog_collections = [ c for c in kb.collections if c.startswith(CATALOG_COLLECTION_PREFIX) ] + collections = [SKILLS_COLLECTION] + catalog_collections fetch_k = top_k * 3 if tag else top_k results: list[dict] = [] for coll in collections: @@ -354,6 +355,14 @@ async def _handle_search_skills( results.sort(key=lambda r: r.get("score", 0), reverse=True) results = results[:top_k] if not results: + if not catalog_collections: + return ( + "No skills found matching the query. Note: no external skill " + "catalog is synced yet, so only installed skills were searched. " + "Call list_skill_sources() to see available sources, then " + "add_skill_source(source=...) (e.g. 'scientific' for " + "materials/chem/bio) to sync one before searching again." + ) return "No skills found matching the query." summaries = [] @@ -386,8 +395,10 @@ async def _handle_install_skill( ) -> str: """Install a catalog skill into ``/skills//``. - The skill becomes natively discoverable after the next ``dsagt start`` - (which mirrors installed skills into ``.claude/skills/`` before launch). + The skill's files land on disk immediately, so the agent can use it in the + current session by reading its SKILL.md. *Native* auto-invocation requires + the next ``dsagt start`` (which mirrors installed skills into + ``.claude/skills/`` before launch) plus an agent restart. """ from dsagt.commands.skills_catalog import install_into_project @@ -409,9 +420,13 @@ async def _handle_install_skill( return ( f"{info['action'].capitalize()} skill '{info['name']}' at " - f"{info['dest_dir']}.\n\nIt will be available to the agent natively " - f"(.claude/skills/) on the next `dsagt start`; restart the agent to " - f"pick it up." + f"{info['dest_dir']}.\n\n" + f"Usable now — its SKILL.md and any scripts/references are already on " + f"disk in this project. To use it this session, read " + f"{info['dest_dir']}/SKILL.md and follow it; you don't need to restart.\n" + f"Restart is only for hands-free auto-invocation: the next `dsagt start` " + f"mirrors it into the platform's native skill dir (.claude/skills/), and " + f"after relaunch the agent discovers and auto-invokes it without this tool." ) diff --git a/src/dsagt/dsagt_instructions.md b/src/dsagt/dsagt_instructions.md index 385c762..cebfcdd 100644 --- a/src/dsagt/dsagt_instructions.md +++ b/src/dsagt/dsagt_instructions.md @@ -25,7 +25,16 @@ Before implementing anything, search for existing capabilities: - `search_skills(query)` — find agent skills (workflows, templates, procedures) - `get_registry()` — list all registered tools -**Skills come in two tiers.** *Installed* skills (in this project) are discovered **natively** by your platform — their names/descriptions are already in your context and you auto-invoke them; you do NOT need `search_skills` to find those. Use `search_skills` to browse the much larger *external catalog* of installable skills (entries marked `[catalog]`), which are NOT loaded into context. To add a catalog skill to the project, call `install_skill(skill_name=...)`; it becomes natively available after the next session restart. To enable another catalog source (a known name like `scientific`/`anthropic`, or a GitHub URL), call `add_skill_source(source=...)`. To author a brand-new skill, use the bundled `skill-creator` skill. +**Skills come in two tiers.** *Installed* skills (in this project) are discovered **natively** by your platform — their names/descriptions are already in your context and you auto-invoke them; you do NOT need `search_skills` to find those. Separately there is a much larger *external catalog* of installable skills (entries marked `[catalog]`), NOT loaded into context. + +**The external catalog is opt-in and starts empty — sources must be synced before `search_skills` can see them.** A blank/weak `search_skills` result usually means the relevant source isn't synced yet, NOT that no such skill exists. So before concluding the catalog has nothing, call `list_skill_sources()` — it reports each known source with its `synced` flag and `indexed` count. The flow: + +1. `list_skill_sources()` — see which sources are already synced vs only `available` (known name + URL, not yet indexed). For materials/chem/bio/DFT skills, the `scientific` source (K-Dense) is the one to enable. +2. `add_skill_source(source=...)` — sync a source (a known name like `scientific`/`anthropic`, or a GitHub URL). Read-only indexing step; nothing is installed into the project. Only needed for sources whose `synced` is false. +3. `search_skills(query)` — now browse the synced catalog. Entries marked `[catalog]` are installable. +4. `install_skill(skill_name=...)` — copy a catalog skill into the project. Its SKILL.md + scripts land on disk immediately, so you can **use it this session** by reading `skills//SKILL.md` and following it. A restart (next `dsagt start`) is only needed for hands-free *native* auto-invocation, not for use. + +To author a brand-new skill instead of installing one, use the bundled `skill-creator` skill. **When the user indicates they want a specific tool used** — phrasings like "use tool `foo`", "use `foo` from the registry", "run `foo`", or similar — look it up first (`search_registry(tool_name=...)` for exact match, `get_registry()` to browse). Read the returned spec's `executable` field and each parameter's `cli` field, then invoke via your shell. Do not substitute your own file/shell tools for a task a registered tool can do. (See section 1b for the verbatim-`executable` rule.) diff --git a/tests/test_knowledge_server.py b/tests/test_knowledge_server.py index a05c35f..49c4cba 100644 --- a/tests/test_knowledge_server.py +++ b/tests/test_knowledge_server.py @@ -13,8 +13,7 @@ import asyncio import json import time -from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import pytest import mcp.types as types @@ -117,8 +116,12 @@ def test_list_skill_sources_returns_known(self, mock_kb): mock_kb.collections = [] server = create_knowledge_server(mock_kb) result = call_tool(server, "list_skill_sources", {}) - assert "scientific" in result["known_sources"] - assert result["synced_collections"] == [] + assert "scientific" in result["sources"] + # Nothing synced → every known source flagged available, not synced. + assert result["sources"]["scientific"]["synced"] is False + assert result["sources"]["scientific"]["indexed"] == 0 + assert result["other_synced_collections"] == [] + assert "scientific" in result["note"] def test_add_skill_source_bad_source_errors(self, mock_kb): mock_kb.collections = [] diff --git a/tests/test_registry_server.py b/tests/test_registry_server.py index 81c5eb2..206ff46 100644 --- a/tests/test_registry_server.py +++ b/tests/test_registry_server.py @@ -624,6 +624,16 @@ def test_save_tool_indexes_into_kb(self, tmp_path): assert len(results) > 0 assert any("csv_filter" in r["chunk"].get("text", "") for r in results) + def test_search_skills_empty_catalog_hints_to_sync(self, tmp_path): + """With no catalog synced, search_skills explains how to enable one + instead of returning a bare 'no match' the agent reads as exhausted.""" + server, reg, kb = _make_server_with_kb(tmp_path) + + text = call_tool(server, "search_skills", {"query": "vasp pymatgen dft"}) + assert "No skills found" in text + assert "no external skill catalog is synced" in text.lower() + assert "add_skill_source" in text + def test_search_registry_by_name(self, tmp_path): """Exact tool_name lookup returns the tool.""" server, reg, kb = _make_server_with_kb(tmp_path) From 6af5d0b67ef61d80dbacf698434647c8de8b3613 Mon Sep 17 00:00:00 2001 From: aarontuor Date: Tue, 23 Jun 2026 13:41:09 -0700 Subject: [PATCH 05/44] chore(release): 0.2.0 with single-sourced version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump to 0.2.0 and make dsagt.__version__ the single source of truth — pyproject reads it via setuptools dynamic metadata, so future bumps touch one line. Add CHANGELOG (Keep a Changelog format). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 49 +++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 5 ++++- src/dsagt/__init__.py | 4 +++- 3 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..92eb9e2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,49 @@ +# Changelog + +All notable changes to DSAgt are documented here. The format is based on +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project +adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.2.0] - 2026-06-23 + +### Added +- External skill catalogs: discover and install agent skills from GitHub + sources via `add_skill_source`, `search_skills`, and `install_skill` (plus + the `dsagt skills sync/add/list/search` CLI), backed by per-source ChromaDB + collections. +- Native skill discovery: installed and bundled skills are mirrored into the + agent's native skill directory (e.g. `.claude/skills/`) at init/start. +- `skill-creator` bundled skill for authoring new skills from the Anthropic + template. +- Install-from-GitHub instructions for non-developers (`pip install + git+https://github.com/AI-ModCon/dsagt.git` into any Python 3.12/3.13 + environment) in the README and docs. + +### Changed +- `search_skills` now reports when no external catalog is synced instead of a + bare "no match", and `list_skill_sources` flags each known source as + `synced`/available with its indexed count. +- `install_skill` clarifies that an installed skill is usable in the current + session immediately — a restart is only needed for hands-free native + auto-invocation. +- The package version is single-sourced from `dsagt.__version__` (pyproject + reads it via setuptools dynamic metadata). +- Documentation home page (`docs/index.md`) pulls the supported-agents table + and install instructions directly from the README via the + `mkdocs-include-markdown` plugin, so the two no longer drift. + +### Fixed +- CLI-added skill sources are now persisted to the project config. + +## [0.1.0] - 2026-01-11 + +### Added +- Initial release: registry and knowledge MCP servers, BYOA per-agent config + generation, MLflow/OTel observability, the tool/skill registry, execution + provenance, and explicit + episodic memory. + +[Unreleased]: https://github.com/AI-ModCon/dsagt/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/AI-ModCon/dsagt/releases/tag/v0.2.0 +[0.1.0]: https://github.com/AI-ModCon/dsagt/releases/tag/v0.1.0 diff --git a/pyproject.toml b/pyproject.toml index bcd0c77..0c9d805 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "dsagt" -version = "0.1.0" +dynamic = ["version"] description = "DataSmith Agent - AI-assisted data pipeline builder" readme = "README.md" requires-python = ">=3.12,<3.14" @@ -81,6 +81,9 @@ build-backend = "setuptools.build_meta" [tool.setuptools] package-dir = {"" = "src"} +[tool.setuptools.dynamic] +version = {attr = "dsagt.__version__"} + [tool.setuptools.packages.find] where = ["src"] diff --git a/src/dsagt/__init__.py b/src/dsagt/__init__.py index 5ad0529..8337be1 100644 --- a/src/dsagt/__init__.py +++ b/src/dsagt/__init__.py @@ -4,7 +4,9 @@ AI-assisted data pipeline builder for MCP-compatible agents. """ -__version__ = "0.1.0" +# Single source of truth for the package version: pyproject.toml reads this +# via `[tool.setuptools.dynamic] version = {attr = "dsagt.__version__"}`. +__version__ = "0.2.0" # Cap CPU thread count for embedding / tokenization libraries before any # heavy imports happen. Without this, PyTorch / sentence-transformers / From f7fc77c463539a25fe5b497935896e433193fb79 Mon Sep 17 00:00:00 2001 From: aarontuor Date: Tue, 23 Jun 2026 13:41:25 -0700 Subject: [PATCH 06/44] docs: add pip-from-github install, sync README/docs, build on uv - README/docs: document the non-developer install (pip install git+https://github.com/AI-ModCon/dsagt.git into any 3.12/3.13 env); note uv is dev/CI-only and conda/venv both work - de-duplicate the supported-agents table and install block via mkdocs-include-markdown so docs/index.md pulls them from the README - correct the Python prerequisite to 3.12/3.13 - cli.md: drop the uv-sync-specific install assumption - docs CI builds with the locked docs dependency group via uv Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/docs.yml | 9 ++++++--- README.md | 41 +++++++++++++++++++++++++++++++++++++- docs/cli.md | 2 +- docs/index.md | 30 +++++++++++++++++++--------- mkdocs.yml | 4 ++++ pyproject.toml | 1 + 6 files changed, 73 insertions(+), 14 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 0cae487..9ed5cb7 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -21,12 +21,15 @@ jobs: with: python-version: "3.12" + - name: Install uv + uses: astral-sh/setup-uv@v5 + - name: Install docs dependencies - run: pip install mkdocs-material + run: uv sync --group docs - name: Build docs - run: mkdocs build --strict + run: uv run mkdocs build --strict - name: Deploy to GitHub Pages if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' - run: mkdocs gh-deploy --force + run: uv run mkdocs gh-deploy --force diff --git a/README.md b/README.md index de1bc88..5f0b8e7 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,9 @@ DSAgt connects an MCP-compatible AI coding agent to tool registration, a semantic knowledge base, execution provenance, and observability infrastructure. DSAgt provides data-pipeline scaffolding around a user's existing agent CLI or VS Code extension (Claude Code, Goose, Codex, …); -**Prerequisites:** Python 3.10–3.13, [uv](https://github.com/astral-sh/uv), and one of the supported agent platforms below — already installed and authenticated against whatever LLM provider you intend to use. +**Prerequisites:** Python 3.12 or 3.13, and one of the supported agent platforms below — already installed and authenticated against whatever LLM provider you intend to use. ([uv](https://github.com/astral-sh/uv) is only needed for the development install.) + | Agent | Install | Verify | |-------|---------|--------| | [Claude Code](https://github.com/anthropics/claude-code) | `npm i -g @anthropic-ai/claude-code` | `claude --version` | @@ -16,6 +17,44 @@ DSAgt connects an MCP-compatible AI coding agent to tool registration, a semanti | [opencode](https://github.com/sst/opencode) | See [opencode docs](https://opencode.ai/docs/) | `opencode --version` | | [Roo Code](https://github.com/RooCodeInc/Roo-Code) | `npm i -g @roo-code/cli` | `roo --version` | | [Cline](https://github.com/cline/cline) | `npm i -g cline` | `cline --version` | + + +## Installation + +### For use (no development) + + +If you just want to *run* DSAgt against your own data and agent — no repo checkout, no `uv` — install it straight from GitHub into a virtual environment. Any Python 3.12/3.13 environment works (`venv`, conda, etc.); only the `pip install git+…` step is officially supported. + +```bash +python3.12 -m venv ~/.venvs/dsagt # or: conda create -n dsagt python=3.12 && conda activate dsagt +source ~/.venvs/dsagt/bin/activate # (Windows venv: ~\.venvs\dsagt\Scripts\activate) +pip install "git+https://github.com/AI-ModCon/dsagt.git" +dsagt --version # 0.2.0 +``` + +This puts the `dsagt` CLI (and the `dsagt-run` / `dsagt-*-server` helpers) on your PATH. Then build the shared knowledge base once and create your first project: + +```bash +dsagt setup-kb # bundled tools + skills + reference corpora + # (downloads a ~130 MB local embedder on first run) +dsagt init my-project --agent claude # or: goose / codex / opencode / roo / cline +dsagt start my-project +``` + +To upgrade later, reinstall and re-run `setup-kb` to pick up new bundled tools/skills: + +```bash +pip install --upgrade "git+https://github.com/AI-ModCon/dsagt.git" +dsagt setup-kb +``` + +> Pin to a specific release once tags are published, e.g. `pip install "git+https://github.com/AI-ModCon/dsagt.git@v0.2.0"`. + + +### For development + +Clone the repo and use `uv` (editable install with the full test suite) — see [Quick Start](#quick-start) below. ## Quick Start diff --git a/docs/cli.md b/docs/cli.md index e4eb4d6..1866a7e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,6 +1,6 @@ # CLI Reference -All commands are available after installing DSAgt. +All commands are available after [installation](index.md#installation) and activating your virtual environment. ## Project Management diff --git a/docs/index.md b/docs/index.md index 1b7fec3..5f963fc 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,22 +6,34 @@ DSAgt connects an MCP-compatible AI coding agent to tool registration, a semanti ## Supported Agents -| Agent | Install | Verify | -|-------|---------|--------| -| [Claude Code](https://github.com/anthropics/claude-code) | `npm i -g @anthropic-ai/claude-code` | `claude --version` | -| [Goose](https://github.com/block/goose) | See [Goose docs](https://github.com/block/goose#installation) | `goose --version` | -| [Codex](https://github.com/openai/codex) | `npm i -g @openai/codex` | `codex --version` | -| [opencode](https://github.com/sst/opencode) | See [opencode docs](https://opencode.ai/docs/) | `opencode --version` | -| [Roo Code](https://github.com/RooCodeInc/Roo-Code) | `npm i -g @roo-code/cli` | `roo --version` | -| [Cline](https://github.com/cline/cline) | `npm i -g cline` | `cline --version` | + +{% + include-markdown "../README.md" + start="" + end="" +%} ## Prerequisites -- Python 3.12–3.13 +- Python 3.12 or 3.13 - One of the supported agent platforms above, installed and authenticated against your LLM provider +- [uv](https://github.com/astral-sh/uv) — only for the development install ## Installation +### For use (no development) + + +{% + include-markdown "../README.md" + start="" + end="" +%} + +### For development + +Clone the repo and use `uv` (editable install; add `--all-groups` for the test suite): + ```bash pip install https://github.com/AI-ModCon/dsagt/archive/refs/tags/0.1.0.zip ``` diff --git a/mkdocs.yml b/mkdocs.yml index 5fd83a0..dda4d9d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -31,6 +31,10 @@ theme: - content.code.copy - content.code.annotate +plugins: + - search + - include-markdown + markdown_extensions: - admonition - pymdownx.details diff --git a/pyproject.toml b/pyproject.toml index 0c9d805..c0e68d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,6 +72,7 @@ dev = [ ] docs = [ "mkdocs-material>=9.5", + "mkdocs-include-markdown-plugin>=6.0", ] [build-system] From 4b81e43b0a5058e6142dc6787b65c99900d721d9 Mon Sep 17 00:00:00 2001 From: aarontuor Date: Tue, 23 Jun 2026 23:06:47 -0700 Subject: [PATCH 07/44] refactor(skills,mcp): merge MCP servers into one dsagt-server; SkillsCatalog + keyword fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse the two MCP servers (dsagt-registry-server + dsagt-knowledge-server) into a single dsagt-server backed by one shared KnowledgeBase, and finish the skills-discovery refactor from design-notes/genesis-skills-comparison.md §10 and skills-catalog-server-merge.md. Server merge - New commands/dsagt_server.py: create_dsagt_server composes both modules' (tools, handlers) under one Server("dsagt") with a type-dispatched call_tool (registry str passthrough + knowledge dict->json + error wrap); one shared-KB main() with the cross-backend guard living once in _build_kb_from_config. - registry_server / knowledge_server keep create_*_server as thin test-facing wrappers via extracted _registry_tools_and_handlers / _knowledge_tools_and_handlers; their standalone main()s + entry points are removed. - pyproject: dsagt-registry-server + dsagt-knowledge-server -> dsagt-server. - Per-agent MCP config collapsed to one "dsagt" entry across claude/goose/codex/ cline/roo/opencode (+ base helpers, info.py span buckets). - Compat is rebuild-not-migrate: re-run `dsagt start` to regenerate config (README upgrade note + cline .cline-data caveat). No migration shims. Skills discovery - New SkillsCatalog (commands/skills_catalog.py) owns the catalog data plane (sync/install/search/list_sources + ChromaDB-vs-keyword backend selection); SkillRouter is now a thin render/MCP facade over it. - New skill_keyword.py: Genesis-faithful token-overlap scorer (no-embedder fallback). skill_discovery.py: stateless SkillRouter. - Catalog-only search + frontmatter-only indexing; removed the dead skills-collection indexing (SkillRegistry + setup_core_kb bundled skills). - Struck the stale bundled datacard-generator skill (now via `dsagt skills add genesis`); skill-creator is the only bundled skill. Docs/diagram - Architecture figure: one MCP box spanning Knowledge + Registry, "Server" dropped from both labels, Skills -> Skills Catalog; regenerated PNGs. - README/docs/design-notes updated. Tests: new test_dsagt_server.py + test_skill_discovery.py; config-shape, info, and smoke-test assertions updated to the single-server shape. 338 passed / 13 skipped across affected suites; ruff + black clean on changed files. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 109 +++ README.md | 26 +- design-notes/genesis-skills-comparison.md | 516 +++++++++++ design-notes/skills-catalog-server-merge.md | 177 ++++ docs/assets/architecture.png | Bin 229608 -> 119777 bytes docs/tools-skills.md | 4 +- latex/architecture.png | Bin 229608 -> 119777 bytes latex/dsagt.tex | 20 +- latex/skills-routing.png | Bin 0 -> 89524 bytes latex/skills-routing.tex | 89 ++ pyproject.toml | 3 +- src/dsagt/agents/__init__.py | 3 + src/dsagt/agents/base.py | 123 ++- src/dsagt/agents/claude.py | 27 +- src/dsagt/agents/cline.py | 146 ++-- src/dsagt/agents/codex.py | 84 +- src/dsagt/agents/goose.py | 62 +- src/dsagt/agents/opencode.py | 111 ++- src/dsagt/agents/roo.py | 53 +- src/dsagt/commands/cli.py | 66 +- src/dsagt/commands/dsagt_server.py | 247 ++++++ src/dsagt/commands/info.py | 7 +- src/dsagt/commands/knowledge_server.py | 737 +++++++--------- src/dsagt/commands/registry_server.py | 824 +++++++----------- src/dsagt/commands/setup_core_kb.py | 43 +- src/dsagt/commands/skills_catalog.py | 280 +++++- src/dsagt/registry.py | 54 +- src/dsagt/skill_discovery.py | 110 +++ src/dsagt/skill_keyword.py | 101 +++ src/dsagt/skills/datacard-generator/SKILL.md | 88 -- .../reference/datacard_template_v1.md | 498 ----------- .../reference/datasheet_template.md | 320 ------- .../reference/field-prompts.md | 38 - .../reference/introspection-commands.md | 80 -- .../reference/lookup-tables.md | 32 - src/dsagt/skills/skill-creator/SKILL.md | 2 +- tests/smoke_test/run.sh | 16 +- tests/test_config.py | 50 +- tests/test_dsagt_server.py | 81 ++ tests/test_info.py | 6 +- tests/test_registry_server.py | 2 +- tests/test_skill_discovery.py | 229 +++++ tests/test_skills_catalog.py | 141 ++- 43 files changed, 3203 insertions(+), 2402 deletions(-) create mode 100644 CLAUDE.md create mode 100644 design-notes/genesis-skills-comparison.md create mode 100644 design-notes/skills-catalog-server-merge.md create mode 100644 latex/skills-routing.png create mode 100644 latex/skills-routing.tex create mode 100644 src/dsagt/commands/dsagt_server.py create mode 100644 src/dsagt/skill_discovery.py create mode 100644 src/dsagt/skill_keyword.py delete mode 100644 src/dsagt/skills/datacard-generator/SKILL.md delete mode 100644 src/dsagt/skills/datacard-generator/reference/datacard_template_v1.md delete mode 100644 src/dsagt/skills/datacard-generator/reference/datasheet_template.md delete mode 100644 src/dsagt/skills/datacard-generator/reference/field-prompts.md delete mode 100644 src/dsagt/skills/datacard-generator/reference/introspection-commands.md delete mode 100644 src/dsagt/skills/datacard-generator/reference/lookup-tables.md create mode 100644 tests/test_dsagt_server.py create mode 100644 tests/test_skill_discovery.py diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9b4d4b1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,109 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +DSAGT (DataSmith Agent) is an AI-assisted data pipeline builder that exposes MCP (Model Context Protocol) servers to agent platforms (Claude Code, Goose, Roo, Cline, Codex). It helps domain scientists create reproducible, auditable data curation pipelines through iterative, knowledge-driven tool generation. + +## Two run modes + +1. **BYOA (Bring Your Own Agent)** — default for everyday use. `dsagt init --agent ` writes per-agent MCP config artifacts; `dsagt mlflow ` backgrounds MLflow and prints the OTel routing exports the user pastes into the shell that runs `claude` / `goose` / etc. Project / agent / session_id are read from `/dsagt_config.yaml` + `.runtime` (single source of truth, no env-var duplication). `dsagt memory --project X` extracts episodic memory from accumulated traces — but only from proxy-shape traces (see #2). +2. **Proxy mode** — `dsagt start --enable-proxy ` interposes a LiteLLM proxy between the agent and its LLM provider. The proxy autologs every LLM call into MLflow with `mlflow.spanInputs` / `mlflow.spanOutputs` populated, which is the only trace shape `dsagt memory` knows how to extract from. Use this when you want both (a) request/response columns populated in the MLflow UI and (b) episodic memory extraction. Native agent OTel emission (Claude Code, Goose) is visible in the UI but uses a different shape (`api_response_body` log events), so memory extraction skips those traces. + +## Commands + +```bash +uv sync --all-groups # install +uv run --no-sync python -m pytest tests/test_.py -q # targeted tests +uv run black . # format +uv run ruff check . # lint +``` + +**`python -m pytest` not bare `pytest`.** The bare-binary form picks up the wrong pytest on this machine and crashes with `ModuleNotFoundError: dsagt`. + +**Don't run the full suite by default.** ~50s for 547 tests is too slow for an iteration loop. Run only the test file relevant to the change. `tests/test_config.py` covers session, init, agents, BYOA hints, launch shim, and memory state. Skip `test_integration.py`, `test_*_integration.py`, `test_server_startup.py`, `test_dependency_integration.py` unless explicitly relevant — they hit network or spawn subprocesses. + +## Code Organization + +The codebase separates **commands** (entry points with argparse, launched as CLI tools or subprocesses) from **modules** (importable logic). Commands live in `src/dsagt/commands/`, modules live in `src/dsagt/`. + +**Commands** (`src/dsagt/commands/`): +- `cli.py` — `dsagt init / mlflow / memory / info / list / mv / rm / setup-kb / smoke-test / stop / start` (user-facing CLI). `dsagt start --enable-proxy` activates proxy mode; without `--enable-proxy` it's the supervised BYOA equivalent (start MLflow + agent under one process tree). +- `proxy_server.py` — `dsagt-proxy` (LiteLLM proxy with OTel autolog). Spawned by `dsagt start --enable-proxy`. +- `run_tool.py` — `dsagt-run` (tool execution wrapper). +- `registry_server.py` — `dsagt-registry-server` (MCP server). +- `knowledge_server.py` — `dsagt-knowledge-server` (MCP server). +- `setup_core_kb.py` — core KB setup (called via `dsagt setup-kb`). +- `info.py` — `dsagt info` (project / config introspection). + +**Modules** (`src/dsagt/`): +- `session.py` — Project init, agent config generation, env-var resolution, config load/validate, service start/stop, end-of-session memory extraction orchestration. +- `agents/` — Per-agent-platform setup (`base.py` ABC + `claude.py` / `goose.py` / `cline.py` / `roo.py` / `codex.py`). Each subclass owns its `write_static`, `write_dynamic`, `env_overrides`, `byoa_env_hints`, `launch_oneliner`. Shared helpers (`_mcp_env_block`, `_render_launch_shim`, `_build_mcp_servers_dict`) in `base.py`. +- `knowledge.py` — FAISS/ChromaDB document retrieval, embedding backends, per-collection routing. +- `registry.py` — `ToolRegistry` (CLI tools) + `SkillRegistry` (agent instruction skills), KB indexing. +- `provenance.py` — Tool execution records (`run_and_record`, `ToolRecordStore`), execution-record indexing into ChromaDB, pipeline reconstruction (`reconstruct_pipeline`, dependency graph). +- `observability.py` — MLflow / OTel tracing setup, `init_tracing`, span helpers. +- `memory.py` — Explicit memory (YAML), episodic-memory extraction prompt + LLM call, outlier detection, `extract_session`. + +Entry points are defined in `pyproject.toml` `[project.scripts]` and all point to `dsagt.commands.*:main`. + +**Bundled assets** (shipped as `package-data` in `pyproject.toml`): +- `src/dsagt/tools/` — built-in tool specs (markdown + YAML frontmatter) copied into new projects. +- `src/dsagt/skills/` — built-in skills (e.g., `datacard-generator`) the agent discovers via `search_skills`. +- `src/dsagt/dsagt_instructions.md` — agent-platform-agnostic system instructions injected into per-agent files at init. + +**`use_cases/`** holds end-to-end domain walkthroughs (`microbial_isolates/`, `cryoem/`, `isaac_vasp/`). They are reference material for users, not part of the test suite. `isaac_vasp/` is currently in active development on this branch. + +## BYOA artifacts + +`dsagt init --agent X --location ` writes (in the project dir): +- `dsagt_config.yaml` — internal config (project name, agent, mlflow port pinned at init, embedding/knowledge/extraction settings). No user-facing fields, no credentials. +- Per-agent instructions file (e.g., `CLAUDE.md`, `.goosehints`, `AGENTS.md`). +- Per-agent MCP config artifact (`.mcp.json` for claude, `goose.yaml` for goose, `cline_mcp_settings.json` via `cline mcp add`, `.roo/mcp.json`, `.codex-data/config.toml`). All include the env block (DSAGT_PROJECT, DSAGT_PROJECT_DIR, MLFLOW_TRACKING_URI, EMBEDDING_*) so MCP-server children that don't inherit shell env still log to the right MLflow. +- `dsagt-launch.sh` — bash shim that exports all dsagt-internal env (DSAGT_*, MLFLOW_*, OTEL_*, agent-specific telemetry verbosity flags), resolves the OTel experiment-id header at run time via curl, then execs the agent. The user runs this directly to launch. + +`dsagt memory --project X` tracks a high-water-mark in `/.dsagt/extracted_at.json` so re-runs only process new traces. + +## Architecture + +### MCP Servers + +1. **Registry Server** (`commands/registry_server.py` + `registry.py`) — Tool analysis, registration, dependency installation. Tools are saved as skill files (markdown with YAML frontmatter). +2. **Knowledge Server** (`commands/knowledge_server.py` + `knowledge.py`) — Semantic search over document collections using FAISS + ChromaDB with optional cross-encoder reranking. Background jobs for long operations. + +### Observability + +- **MLflow** — Token usage, cost, latency, full LLM-call traces via OTel. Started by `dsagt mlflow ` (foreground, in its own terminal). Port is pinned at init time and lives in `dsagt_config.yaml`. +- **dsagt-run** (`commands/run_tool.py` + `provenance.py`) — Wraps tool commands; captures execution layer (command, stdout/stderr, timing, file lists) into `trace_archive/`. +- **MCP-server OTel** — Both servers call `init_tracing()` at startup; their tool spans (kb.*, registry.*) flow to MLflow alongside the agent's LLM-call spans. + +### Memory System + +- **Episodic memory** (`memory.py:extract_session`) — End-of-session LLM extraction of facts from MLflow traces into ChromaDB, with per-category outlier detection via embedding centroids. Triggered by `dsagt memory --project X`. +- **Explicit memory** (`memory.py:ExplicitMemory`) — User-confirmed facts in YAML, loaded into agent context at session start. + +### Key Design Patterns + +- **Agent-agnostic**: DSAGT is infrastructure, not an agent. Capabilities are MCP services. +- **Session isolation**: Each project gets its own directory with config, tools, skills, kb_index, trace_archive, mlflow data. +- **Tools vs Skills**: Tools are CLI executables in `/tools/` (specs with parameters, wrapped by dsagt-run). Skills are agent instruction workflows in `/skills/` (SKILL.md + reference docs). Both are discoverable via ChromaDB-backed semantic search. + +## DSAGT Pipeline Builder Workflow + +When acting as a pipeline builder (using the MCP servers), follow these constraints: + +1. **Never directly access data** — all data operations go through registered tools. +2. **Tool preference hierarchy**: Registered tool → KB package tool → Custom implementation. +3. **Generate paired tools** — every data operation gets a check tool (pre/post audit) and an operation tool. +4. **Audit everything** — before/after JSON reports saved to `audit/`. +5. **One step at a time** — iterate with the user, confirming approach before execution. + +## Testing Patterns + +- Tests use pytest with `subprocess.run` mocking for command execution. +- MCP server tests invoke handlers directly (no stdio transport). +- Async tests for server handlers. +- Temp directories for isolation; the `_use_tmp_registry` fixture in `tests/test_config.py` patches `DEFAULT_PROJECTS_BASE` and the project registry to `tmp_path`. +- Integration tests in `test_*_integration.py` require real `EMBEDDING_*` / `LLM_*` credentials. +- A handful of tests are class-skipped under `TestProviderEnvInjection` with a long reason about "old-code-shape env_overrides" — those describe the pre-Phase-1 design where `env_overrides` did broad provider-credential translation, which is now narrower (model env-var pinning only; provider creds + base URLs come via `proxy_env_overrides` or per-agent config files). Kept around for reference; safe to delete once Phase 2 is stable on real workloads. diff --git a/README.md b/README.md index 5f0b8e7..8674d71 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ The same flow runs non-interactively via `dsagt smoke-test --agent claude` (or ` `dsagt setup-kb` builds the shared ChromaDB collections under `~/.dsagt/kb_index/` that every project on this machine reuses. Three of the six collections shown in the [architecture diagram](#architecture) are populated here — the other three are per-project and fill in automatically during use (see [Knowledge Base](#knowledge-base) below): - **Tool Specs** — DSAgt's bundled tool specs from `src/dsagt/tools/`, tagged with `source: bundled` so the agent finds them via `search_registry` from the very first session. -- **Skills** — DSAgt's bundled skill workflows from `src/dsagt/skills/` (e.g. `datacard-generator`), discovered via `search_skills`. +- **Skills** — DSAgt's bundled skill workflows from `src/dsagt/skills/` (e.g. `skill-creator`), discovered via `search_skills`. Domain skills (datacards, etc.) come from external catalogs — `dsagt skills add genesis`. - **Domain Knowledge** — Reference corpora (NVIDIA NeMo Curator, AI Data Readiness Inspector) downloaded and embedded so the agent has data-curation domain knowledge out of the box. The Tool Specs and Skills collections are wipe-and-rebuild on every run, so re-run `setup-kb` after upgrading DSAgt to pick up new bundled assets. @@ -181,16 +181,29 @@ Projects are registered in `~/.dsagt/projects.yaml` so `dsagt mlflow ` and # cline: .clinerules/, cline_mcp_settings.json (managed via cline mcp add) ``` -### MCP Servers +### MCP Server -- **Registry** (`dsagt-registry-server`) — Tool registration and dependency installation. Tools are markdown files with YAML frontmatter under `/tools/`. Executables are wrapped with `dsagt-run` for provenance and `uv run --with` for Python dependencies. The agent discovers tools via `search_registry`. -- **Knowledge** (`dsagt-knowledge-server`) — Semantic search over indexed document collections (FAISS / ChromaDB). Background jobs handle long ingest operations. The agent searches via `kb_search`, ingests via `kb_ingest`, and saves user-confirmed facts via `kb_remember`. +DSAGT exposes a single MCP server, **`dsagt-server`**, that an agent connects to once. It bundles two concern areas: + +- **Registry** — Tool registration and dependency installation. Tools are markdown files with YAML frontmatter under `/tools/`. Executables are wrapped with `dsagt-run` for provenance and `uv run --with` for Python dependencies. The agent discovers tools via `search_registry`. +- **Knowledge** — Semantic search over indexed document collections (FAISS / ChromaDB). Background jobs handle long ingest operations. The agent searches via `kb_search`, ingests via `kb_ingest`, and saves user-confirmed facts via `kb_remember`. + +> **Upgrading from the two-server layout?** Earlier versions ran separate `dsagt-registry-server` and `dsagt-knowledge-server` processes. There is no automatic migration: re-run `dsagt start ` and the per-agent MCP config is regenerated to point at the single `dsagt-server`. For **cline** specifically, `cline mcp add` has no remove, so an upgraded project keeps stale `dsagt-registry`/`dsagt-knowledge` entries alongside the new `dsagt` one — delete `/.cline-data` before `dsagt start` to get a clean config. ### Tools and Skills **Tools** are CLI executables defined as markdown files with YAML frontmatter in `/tools/`. The agent registers new tools via the registry MCP server's `save_tool_spec`. -**Skills** are instruction-based agent workflows in `/skills/`. Each skill is a directory containing a `SKILL.md` and optional reference docs. DSAgt ships with a bundled `datacard-generator` skill. The agent discovers skills via `search_skills`. +**Skills** are instruction-based agent workflows — a directory with a `SKILL.md` and optional reference docs. They come in two tiers: + +- **Installed** skills live in `/skills/` (DSAgt ships a bundled `skill-creator`; domain skills like the MODCON datacard generator are installed from the `genesis` catalog). These are mirrored into the agent's native skill directory (e.g. `.claude/skills/`, `.agents/skills/`) at `dsagt init`/`start` so the agent auto-invokes them, and they are also searchable via `search_skills`. +- **Catalog** skills come from external Git repositories — GitHub *or* GitLab — indexed into a searchable catalog the agent browses with `search_skills` but that is **not** loaded into its context (so a catalog can hold thousands of skills). The agent enables a source with `add_skill_source(...)`, finds skills with `search_skills(...)`, then copies one into the project with `install_skill(...)`. + +The catalog is **opt-in**: a source must be synced before its skills are searchable. Curated named sources ship out of the box — `scientific`, `anthropic`, `antigravity`, `composio`, and `genesis` (the OSTI GENESIS catalog: HPC, HuggingFace, LangChain, OpenAI, plasma-sim, and more) — and any Git URL or `owner/repo` works too. Manage catalogs from the CLI with `dsagt skills list/search/add/sync `, or from the agent with `list_skill_sources` / `add_skill_source` / `search_skills` / `install_skill`. + +![DSAgt skills routing](latex/skills-routing.png) + +Skill handling runs through one service over two stores. **`SkillRouter`** is the single skill-MCP entry point — every skill tool routes through it: `add_skill_source` / `list_skill_sources` manage repos, `search_skills` queries the catalog, `install_skill` adopts a catalog skill into the project. **Registration** pulls skills from External Skills Repos (the curated `scientific` / `anthropic` / `antigravity` / `composio` / `genesis` sources, *or any git URL*) into the **Skills Catalog** — a federated, searchable store of *not-yet-installed* skills (semantic search, with a zero-dependency keyword fallback when no embedder is configured). **Discovery** is the catalog's irreplaceable job: surfacing skills the agent doesn't yet have, which native discovery can't see. **Progressive exposure** is native: the **Skill Directory** holds the project's installed + created skills in each agent's own skill dir (`.claude/skills`, `.agents/skills`, `.cline/skills`, `.roo/skills`), where the agent auto-discovers and model-invokes them by relevance — and authors new ones via the bundled **`skill-creator`** skill. The diagram source is [`latex/skills-routing.tex`](latex/skills-routing.tex). ### Knowledge Base @@ -207,7 +220,7 @@ Six independently-partitioned ChromaDB collections hold everything the agent sea The default embedding backend is local (sentence-transformers, CPU-side, no API needed). Switch to `embedding.backend: api` in `dsagt_config.yaml` to route through a hosted embedder via LiteLLM. Cross-encoder reranking is optional (`knowledge.rerank: true`). -The agent searches via `kb_search` (knowledge MCP server) and writes via `kb_ingest` / `kb_remember`. Tool Specs and Skills are queried through specialized routes (`search_registry`, `search_skills`) over the same backend. +The agent searches via `kb_search` (knowledge MCP server) and writes via `kb_ingest` / `kb_remember`. Tool Specs and Skills are queried through specialized routes (`search_registry`, `search_skills`) over the same backend. Enabling external skill catalogs adds one `skills_catalog__` collection per source, which `search_skills` queries alongside the bundled Skills collection. ### Observability @@ -229,6 +242,7 @@ Every span carries the project's `session.id` for filtering. Tool execution reco | `dsagt memory --project ` | Distill new traces from this project's MLflow into episodic memory | | `dsagt info [--json]` | Resolved config (with source per value) and a session/error summary | | `dsagt setup-kb [--collection ]` | Build the shared core knowledge base collections | +| `dsagt skills [source]` | Manage external skill catalogs and project skill installs | | `dsagt list` | List all projects with agent, status, and path | | `dsagt mv ` | Move a project to a new location | | `dsagt rm [-y] [--keep-files]` | Unregister a project (and optionally delete its directory) | diff --git a/design-notes/genesis-skills-comparison.md b/design-notes/genesis-skills-comparison.md new file mode 100644 index 0000000..aed6644 --- /dev/null +++ b/design-notes/genesis-skills-comparison.md @@ -0,0 +1,516 @@ +# Design Note — Genesis Skills vs DSAGT skill discovery + +**Status:** implemented (2026-06-23). Sections 0–6 are the original close read of +the Genesis Skills discovery engine vs DSAGT's. Sections 7–9 record the agreed +design as it stood mid-plan. **§10 records what actually shipped and supersedes +the tier-3 / progressive-disclosure / recency-queue parts of §7.4–7.6 and §9** — +a research pivot (every supported agent natively discovers SKILL.md) collapsed +those. Read §10 for the final architecture; §7–9 are kept for design history. + +**Date:** 2026-06-23 +**Author:** comparison drafted with Claude Code + +- Genesis Skills: + (the `skill-search/` engine + a curated 74-skill catalog under `skills/`) +- DSAGT skill discovery: [skills_catalog.py](../src/dsagt/commands/skills_catalog.py), + [registry.py](../src/dsagt/registry.py) (`SkillRegistry`), + [registry_server.py](../src/dsagt/commands/registry_server.py) (`search_skills`/`install_skill`), + [knowledge_server.py](../src/dsagt/commands/knowledge_server.py) (`add_skill_source`/`list_skill_sources`) + +--- + +## 0. Relationship (important framing) + +This is **not a competitor** — it's a sibling DOE effort. Genesis Skills is part +of the "Genesis Mission" (`genesis@osti.gov`, gitlab.osti.gov/genesis), and its +catalog includes a **`modcon-skills/`** category (`datacard-generator`, +`croissant-validator`, `hdmf-schema-builder`) — i.e. our own BASE-Data/ModCon +skills. DSAGT (`AI-ModCon`, BASE-Data team) and Genesis are in the same orbit. + +DSAGT already **consumes** Genesis as a catalog source (the `genesis` entry in +`KNOWN_SOURCES`, subdir `skills`, ~72 of 74 skills index cleanly; 2 are skipped +for malformed upstream YAML). So the natural relationship is **complementary**: +DSAGT = semantic, multi-source, platform-integrated layer that *indexes* Genesis +(and others); Genesis = portable, standard-conformant content + lightweight +discovery. + +--- + +## 1. What Genesis built + +A focused, polished, single-purpose **skill-discovery engine** (`skill-search/`) +plus a curated catalog (74 skills across 10+ domains). + +- **Search method:** pure-Python **keyword token-overlap** scoring + (`skill-search/skill_search/catalog.py`). Name-token matches ×2, + description-token matches ×1, plus exact/substring bonuses (+6 exact name, + +4 substring name, +2 substring description), stopword filtering, deterministic + tie-break by name. **Zero dependencies, no embeddings, no DB, no model + download.** +- **Two discovery strategies:** + 1. `--query` → top-k keyword matches (keeps full catalog out of context). + 2. `--load-all` → progressive disclosure: compact index of *all* skills + (name/description/path) upfront, load `SKILL.md` body on demand. + 3. `--include-prompt` → emits an `` XML block for direct + system-prompt injection. +- **Discovery:** filesystem recursion over nested `skills///`, + merged by name with **override semantics** (central catalog + user roots; later + roots win). No persistent index — re-scanned each call. +- **The engine is itself a Skill** (`SKILL.md`, `allowed-tools: Bash`): the agent + runs it via Bash and gets JSON back. **No server.** +- **Distribution:** `unpack.sh` flattens/symlinks skills into an agent's native + dir across **multiple harnesses** (Claude Code, Gemini, Codex `.agents/skills/`); + LangChain/LangGraph import the `skill_search` package directly. +- **Standard conformance:** explicitly follows the **agentskills.io open + standard** (`skill_spec.md` mirrors the spec), points at `skills-ref validate`, + and has real license hygiene (Apache-2.0, `NOTICE` inventory, per-subtree + licenses for vendored third-party skills). +- **Engineering polish:** standalone package (`catalog`/`frontmatter`/`models`/ + `prompt`/`tooling`/`errors`), ~500 lines of unit tests, layered deployment + resolution (`--central-root` flag → `SKILL_SEARCH_CENTRAL_ROOT` env → sibling + `../skills`). + +--- + +## 2. What DSAGT has + +Skill discovery is **one feature inside a platform** (KB, tool registry, +provenance, observability, memory), exposed over MCP. + +- **Search method:** **semantic embeddings + BM25 hybrid** over ChromaDB + (local `bge-small-en-v1.5` or hosted via LiteLLM; `route.json` `hybrid: true`, + `bm25.pkl` present). Better recall on natural-language queries. +- **Index:** persistent **per-source ChromaDB collections** + (`skills_catalog__`), built by `sync_source` (clone → index). Sparse + clone via `subdir`. Machine-global cache at `~/.dsagt/.skill_sources/`. +- **Sources:** many **remote** sources (GitHub *and* GitLab), curated + `KNOWN_SOURCES` (`scientific`, `anthropic`, `antigravity`, `composio`, + `genesis`) **plus any git URL / `owner/repo`**. +- **Runtime:** MCP server tools the agent calls directly — `search_skills`, + `add_skill_source`, `list_skill_sources`, `install_skill` — plus the + `dsagt skills sync/add/list/search` CLI. +- **Two tiers:** *installed* skills (mirrored into native `.claude/skills/` at + init/start, auto-invocable) vs *catalog* skills (indexed, not in context). +- **Dependencies:** requires an embedder (~130 MB local model or an API key). + +--- + +## 3. Side-by-side + +| Dimension | Genesis `skill-search` | DSAGT | +|---|---|---| +| Search | Keyword token-overlap, deterministic | Semantic embeddings + BM25 hybrid | +| Index | None; re-scans filesystem each call | Persistent per-source ChromaDB | +| Sources | One filesystem tree (its own repo) | Many remote sources (GitHub + GitLab), curated + arbitrary URL | +| Runtime | A Bash-invoked Skill; no server | MCP server tools + CLI | +| Dependencies | Zero (stdlib only) | Embedder (~130 MB local or API) | +| Scope | Just skill discovery/distribution | One feature in a platform (KB/registry/provenance/memory) | +| Standard | agentskills.io conformant + validation | Own SKILL.md convention (close, not formalized) | +| Multi-harness install | `unpack.sh` (Claude/Gemini/Codex) | Mirrors to `.claude/skills/` (per-agent) | +| Context modes | Search top-k, full index, prompt-XML | Search returns summaries; catalog kept out of context | +| Tests/packaging | Dedicated package + ~500 LOC unit tests | Solid, embedded in the larger codebase | +| License hygiene | NOTICE + per-subtree licenses | Not tracked on `install_skill` copy | + +--- + +## 4. Honest assessment + +**Where Genesis is better developed (the narrow slice):** +- Open-standard conformance + validation tooling. +- Portability — no server, works across harnesses, the engine ships *as a skill*. +- Zero-dependency, deterministic, instant — no embedder download, no API, reproducible. +- Cleaner standalone package with tests; proper license/attribution for vendored skills. +- A large curated catalog ready to go. + +**Where DSAGT is stronger (what matters for the platform):** +- Semantic + hybrid search beats keyword overlap on fuzzy/natural queries. + (Keyword overlap misses synonymy: "submit a batch job" won't score `slurm` + unless tokens literally overlap.) +- Multi-source, persistently-indexed catalog federating many remote hosts — + Genesis searches one tree; DSAGT federates Genesis *plus* K-Dense, Anthropic, … +- Integration: skills live alongside the KB, tool registry, provenance, and + memory in a reproducible pipeline workflow. + +--- + +## 5. Borrowables (candidate work items, prioritized) + +1. **Keyword fallback for `search_skills` when no embedder is configured.** + Today `search_skills` dead-ends with "requires a configured knowledge base" + when `kb is None`. Genesis's token-overlap scorer + (`catalog.py:_score_skill` / `rank_skills`) is exactly the zero-dependency + fallback that would make search work without the 130 MB model. **Highest + value / lowest cost**; directly fixes the no-embedder gap. +2. **Adopt the agentskills.io standard explicitly** for DSAGT's SKILL.md + (already ~compatible) and add a validation step (`skills-ref validate` or + equivalent). Improves interop — and we now feed from a standard-conformant + source. +3. **Progressive-disclosure prompt block** (`` XML) — a cheap + mode for small catalogs that skips the index entirely. +4. **License / NOTICE hygiene on `install_skill`.** When a (possibly + third-party) catalog skill is copied into a project, preserve its `LICENSE`. + Genesis tracks this per-subtree; DSAGT currently doesn't. +5. **Lean into complementarity.** Keep DSAGT as the semantic, multi-source, + platform-integrated layer that indexes Genesis (and others). Consider + coordinating with the Genesis team — modcon-skills already overlaps, so + there's a shared-content story. + +--- + +## 6. Open questions for the merge decision (pick up later) + +- Do we want DSAGT to *re-export* an agentskills.io-conformant catalog (so other + tools, incl. Genesis `skill-search`, can consume DSAGT's skills)? +- Should the keyword fallback (#1) be a permanent low-tier search mode (fast, + cheap, deterministic) selectable even when an embedder *is* available? +- Is there appetite to upstream DSAGT/ModCon skills into Genesis rather than + maintain parallel copies (the `modcon-skills/` overlap)? +- Packaging: is the Genesis `skill_search` Python package worth depending on + directly for the fallback, or do we reimplement the (small) scorer to avoid a + dependency on an external repo? + +--- + +## 7. Decided plan (2026-06-23) + +The architecture below is **not a rewrite** — DSAGT's existing chain already *is* +the curation + per-project-install model we want. The plan is a set of insertions +on top of it. + +### 7.1 Earmarked borrowables (committed) + +1. **Keyword fallback for `search_skills` when `kb is None`.** Reimplement (do + **not** depend on) Genesis's `_score_skill` / `rank_skills` token-overlap + scorer — ~50 LOC, vendored with attribution. Insert it *before* the + `kb is None` dead-end at `registry_server.py:324`. It reads frontmatter off + the on-disk clone cache (`~/.dsagt/.skill_sources/`) + bundled skills, which + already exists because `sync_source` clones even when `kb is None` + (`skills_catalog.py:218`). No embedder, no separate index. + *Status (built):* `src/dsagt/skill_keyword.py` mirrors Genesis exactly — + weights (name ×2 / desc ×1), **mutually-exclusive** substring bonus tiers + (+6 exact / +4 name / +2 desc, via `elif`), the same stopword set, and the + casefold-`\w+`-hyphen-split tokenizer that drops single-char tokens. + Verified against the upstream source, not just the prose spec. It is strictly + the *no-KB* path; when a KB exists the router uses ChromaDB (whose hybrid mode + already includes BM25), so the scorer and BM25 are mutually exclusive and + never double-rank. +2. **License / NOTICE hygiene on `install_skill`.** `install_into_project`'s + `copytree` (`skills_catalog.py:313`) already carries a *skill-dir-local* + LICENSE. The gap is the source repo's **root NOTICE / per-subtree license + provenance** — capture it at sync time (Stage A) and stamp it at install. + +### 7.2 Shipped-skill cut + +DSAGT ships only two skills today — `datacard-generator` (frontmatter name +`generating-datacards`) and `skill-creator` — plus one tool (`scan_directory`). + +- **Strike `datacard-generator`** from the repo; it lives in Genesis + `modcon-skills/`. Users get it via `add_skill_source genesis` (catalog tier). + **Verify the Genesis copy exists and matches before deleting**, then leave a + pointer. This makes Genesis the canonical home (open question #3 becomes a real + coordination dependency). +- **Keep `skill-creator`** as the single minimal shipped skill: it's + *infrastructure*, not domain content, so it doesn't belong in Genesis's curated + domain catalog; it's self-referential (the harness can scaffold test skills with + it); and it's stable. Shipped skill + `scan_directory` tool exist primarily as + **test-harness fixtures**, since no shipped tool does more than wrap standard CLI. + +### 7.3 Current MCP call chain (the anchor) + +``` +add_skill_source / list_skill_sources → search_skills → install_skill → dsagt start + (expose catalogs) (select a subset) (draw into project) (activate natively) + knowledge_server registry_server registry_server → agents/claude.py + skills_catalog _mirror_skills_to +``` + +- **Stage A — Expose.** `add_skill_source` → `resolve_source` → `sync_source`: + sparse-clone by `subdir` into `~/.dsagt/.skill_sources//`, then + `index_catalog` **wipes + rebuilds** that one source's + `skills_catalog__` ChromaDB collection; persists the source to + `dsagt_config.yaml`. `list_skill_sources` reports `KNOWN_SOURCES` + synced + state. (`skills_catalog.py:185`, `knowledge_server.py:609`) +- **Stage B — Select.** `search_skills` searches `SKILLS_COLLECTION` (installed) + **plus every** `skills_catalog__` collection, merges by score, returns + top-k tagged `[installed]` / `[catalog · install_skill to add]`. Dead-ends when + `kb is None` (except exact `skill_name`). (`registry_server.py:305`) +- **Stage C — Draw.** `install_skill` → `find_catalog_skill` (cross-source + ambiguity guard) → `install_into_project` copies the skill dir into + `/skills//`, re-indexes it as `registered`. + (`registry_server.py:390`, `skills_catalog.py:296`) +- **Stage D — Activate.** Next `dsagt start` mirrors `/skills/` → + `.claude/skills/` for native auto-invocation — **only `claude.py` does this**; + goose/cline/roo/codex have no native mirror. (`agents/claude.py:182`, + `agents/base.py:251`) + +Installed skills promoted via `install_skill` become part of the **core installed +set** for future sessions in that project. + +### 7.4 Tiering & backend selection (the core design) + +> ⚠️ **Superseded by §10.** Tiers 2/3 and the budget threshold below assumed some +> agents lack native skill discovery. Research proved otherwise — *all* supported +> agents are native, so tier 3 (and the disclosure block + recency queue it +> motivated) was never built. The *catalog vs installed* split and the +> *ChromaDB-or-keyword* backend selection survive; the rest is history. + + +Progressive disclosure and ChromaDB are **not competitors** — they sit on +different axes: + +- **What is disclosed:** installed (core, project) vs. catalog (federated, remote). +- **How the index is produced:** *full dump* (deterministic, no query) vs. + *query-driven selection* (needs a query, returns top-k). + +Both produce the **same `` block** (the agentskills.io-style +output contract). Backend is chosen by **context budget**, not by tier: + +> Full-dump while the disclosed set fits the budget; switch to query-driven +> *selection* when it doesn't. "Selection" = **ChromaDB if an embedder exists, +> Genesis keyword scorer if not.** ("Fall back on ChromaDB" is shorthand — the +> real fallback is to *selection*, whose backend is itself tiered.) + +Three operating tiers: + +1. **Catalog (any harness)** — *always* query-driven. Unbounded (Genesis + + Anthropic + …), so never full-dumped. ChromaDB top-k, or keyword scorer when + no embedder. +2. **Installed, native harness (Claude)** — dsagt **defers** to the harness. + `_mirror_skills_to` populates `.claude/skills/`; Claude's own runtime injects + names/descriptions and lazy-loads bodies (note the `_NATIVE_DESCRIPTION_CAP` + truncation at `base.py:231` — *Claude's* limit, not dsagt's). dsagt emits no + block here, so its budget threshold never fires. +3. **Installed, non-native harness (goose/cline/roo/codex)** — dsagt **is** the + producer of the block, because the harness has no native discovery. Full-dump + the installed set into the agent's instructions file until the context budget + is hit; past that, drop to a short pointer + query-driven selection. See 7.5. + +### 7.5 The non-native third tier, by example + +On goose/cline/roo/codex an installed skill lands in `/skills//` +but **nothing auto-injects it** — today the agent only finds it by calling +`search_skills`. The third tier closes that gap: dsagt emits the +progressive-disclosure block the harness won't. + +*Goose, 5 installed skills* (`skill-creator`, `generating-datacards`, +`slurm-submit`, `croissant-validator`, `fastq-qc`): at `dsagt start`, dsagt writes +an `` block (name + description + path per skill) into +`.goosehints`. ~5 × 40 ≈ **200 tokens** → full-dump. Goose passively knows all +five and reads a SKILL.md body on demand. No embedder, no query. + +*Same project, 100 installed skills*: the block is now ~4,000 tokens **every +session**. Past the budget, dsagt stops full-dumping, leaves a short pointer +("call `search_skills`…"), and lets selection carry it (ChromaDB top-k, or keyword +scorer). The agent pulls ~3 relevant skills per task instead of carrying all 100. + +This threshold fires **only** in tier 3 — the one case where dsagt both produces +the block and pays its context cost. Codex graduates tier 3 → tier 2 in this pass +by mirroring into its natively-discovered, **project-local** `.agents/skills/` +(§9.7), so it stops going through dsagt's threshold; goose/cline/roo remain +tier 3. + +### 7.6 The discovery router (the consolidating abstraction) + +All of the policy in 7.4–7.5 — *which backend, which tier, full-dump vs select, +installed vs catalog, defer-to-harness vs emit* — lives in **one** place rather +than smeared across the MCP handler, `base.py`, and each agent file. Otherwise the +decision tree gets re-implemented and drifts at every call site. + +**Home:** new module `src/dsagt/skill_discovery.py` (a *module*, not a command), +class `SkillRouter`. It **owns policy and delegates execution.** + +```python +class SkillRouter: + def __init__(self, *, kb, skill_registry, project_dir, agent, config): ... + + def search(self, query=None, *, top_k=8, tag=None, skill_name=None) -> str: + # Stage B. Owns backend choice (ChromaDB vs keyword vs full-dump), + # installed+catalog merge, no-embedder fallback, rendering. + + def disclosure_block(self) -> str | None: + # Stage D. Owns tier resolution + budget threshold. Returns None when + # the harness owns the tier (native). + + def list_sources(self) -> list[dict]: + # Stage A view. KNOWN_SOURCES + per-collection indexed count + synced? + # one consistent view for both the MCP handler and the CLI. +``` + +- **Owns:** backend selection (`_select`: kb→ChromaDB, else keyword scorer), + tier resolution, budget threshold (`_mode_for_installed`), and the single + `` renderer (`_render`, used by *both* `search` and + `disclosure_block`). +- **Delegates (unchanged):** `kb.search`, `sync_source` / `install_into_project` + (`skills_catalog`), `_discover_skill_dirs` / `_parse_frontmatter`, + `_mirror_skills_to`. It is a coordinator, not a reimplementation. +- **Three thin call sites:** `registry_server._handle_search_skills` → + `router.search`; each agent's `write_dynamic` → `router.disclosure_block`; and + the **CLI** `dsagt skills search/list` (`cli.py:_cmd_skills`) → `router.search` + / `router.list_sources`. The CLI is the decisive case: `cli.py:485-509` is + *already* a drifted copy of the `_handle_search_skills` merge logic (no `tag`, + no exact-`skill_name`, no `kb is None` path, different render). The router + collapses both copies into one. +- **`list_skill_sources` exposure status** (`knowledge_server.py:609`) also folds + into `router.list_sources()` — today the MCP handler and CLI `skills list + --catalog` compute "what's synced" differently. +- **Nativeness becomes explicit:** each agent declares `native_skills: bool` + (claude=True, codex=True; goose/cline/roo=False) instead of it being implicit in + "only claude calls `_mirror_skills_to`." The router reads the flag for tier 2 vs + 3. Codex is tier 2 via a **project-local** `.agents/skills/` mirror only (see §9.7). + +**Materialize vs. disclose (don't conflate).** Two separate jobs, both keyed off +`native_skills`: + +- *Materialize* = put skill files where the agent expects them. **dsagt does this + for every tier.** Native (claude): `_mirror_skills_to` → `.claude/skills/`. + Non-native: skills just live in `/skills/` from install (no native dir). +- *Disclose* = make the agent aware of them in context. Native: the harness does + it (router returns `None`). Non-native: `router.disclosure_block()` emits the + block. + +So the router defers tier-2 **disclosure** to Claude — **not** materialization. +dsagt still mirrors into `.claude/skills/`. The agent's `write_dynamic` is +`if native_skills: _mirror_skills_to(...) else: write(router.disclosure_block())`. +(`_mirror_skills_to` stays outside the router as materialization *execution*, not +because dsagt is hands-off for native harnesses.) + +**Recency queue (dedup + un-bury).** SkillRouter owns a length-bounded, +session-scoped FIFO of recently-exposed skill names. Skills emitted in the +disclosure block or returned by `search` are pushed on; while a skill is *fresh* +(in the queue) `search` suppresses re-surfacing it — it's already in context. As +new exposures push it off the tail it ages out and becomes eligible again (by +then likely buried in the transcript). This unifies the old double-listing and +re-emit threads: `install_skill` marks the new skill fresh, so it isn't +redundantly re-surfaced and no disclosure re-emit is needed. The queue **must be +disk-backed** (`/.dsagt/exposed_skills.json`) because the disclosure +block (agent-setup process) and `search` (MCP server process) are separate +processes that share it. *Refinement:* an explicit query that hits a fresh skill +returns a terse "already available" pointer rather than an empty result. + +--- + +## 8. Implementation surface (router-centric) + +Most changes land *inside* `SkillRouter`; call sites stay thin. + +| Change | Where | Kind | +|---|---|---| +| `SkillRouter` (backend select, tier, budget, render) | new `src/dsagt/skill_discovery.py` | New module | +| Vendored keyword scorer (genesis-derived, attributed) | new `src/dsagt/skill_keyword.py`, called by `router._select` | New code | +| `search_skills` → router | `registry_server._handle_search_skills` (replace body with `router.search`) | Thin call site | +| `disclosure_block` → router | `agents/{goose,cline,roo,codex}.py` `write_dynamic` (one-line call) | Thin call site | +| **CLI** `skills search/list` → router | `cli.py:_cmd_skills` (`cli.py:485-509` — kill the drifted dup) → `router.search` / `router.list_sources` | Thin call site | +| `list_skill_sources` → router | `knowledge_server.py:609` → `router.list_sources` | Thin call site | +| `native_skills` flag | each `agents/*.py` (declare True/False) | Declaration | +| License/NOTICE capture | `sync_source` + `install_into_project` (`skills_catalog.py:313`) — data-plane, outside router | Augment | +| Strike `datacard-generator` | `src/dsagt/skills/` + `pyproject.toml` package-data | Deletion | + +**Stays outside the router (delegated data / execution):** `SkillRegistry` +(installed-skills data + indexing — router reads it); `sync_source` / +`index_catalog` / `find_catalog_skill` / `install_into_project` (catalog +execution); `_mirror_skills_to` / `_truncate_native_description` (tier-2 native +mirror); `resolve_source` / `KNOWN_SOURCES` / `persist_source_to_config` (source +config). `_parse_frontmatter` and `_discover_skill_dirs` are already +single-sourced shared primitives — the router imports them, no move needed. + +--- + +## 9. Resolved decisions (2026-06-23) + +1. **Budget threshold** — a **token count** (estimated, e.g. `chars/4` to avoid a + tokenizer dependency), with a sensible default, as a property of `SkillRouter` + (per-agent overridable). The router measures it. +2. **Double-exposure** — solved by the recency queue (§7.6), not a static rule. + While a skill is *fresh* (in the session queue) `search` won't re-surface it. +3. **Mid-session freshness** — **no re-emit.** The agent retains newly installed + skills in context (`install_skill` returns the SKILL.md), and the queue marks + them fresh so `search` won't redundantly re-surface them. +4. **agentskills.io conformance** — adopt only the `` **output** + shape. **Not** full input conformance: strict validation would force + rewrite-or-exclude on third-party repos and add validation/normalization code — + *more* complex, not simpler, so it fails the "only if it simplifies" test. Keep + lenient parsing (parse what we can, skip malformed — as today). +5. **Keyword-scorer latency over large caches** — deferred; revisit only if + non-KB users hit usability issues. +6. **`datacard-generator`** — strike dsagt's copy; it's stale, the Genesis copy is + more current. *Pre-deletion check:* confirm the Genesis copy indexes cleanly + (isn't one of the 2 malformed-YAML skips) before removing ours. +7. **Codex → tier 2 now, project-local only.** Reuse the manifest-tracked + `_mirror_skills_to` pointed at `/.agents/skills/`. **Never** write + global `~/.agents/skills/` or `~/.codex` config, and (via `.dsagt-managed.json`) + never touch user-authored skills — footprint stays confined to the project dir + and transparent. *Verify:* Codex auto-discovers a project-local `.agents/skills/`. + +--- + +## 10. Implementation outcome (shipped 2026-06-23) + +Supersedes the tier-3 / progressive-disclosure / recency-queue parts of §7.4–7.6 +and §9. + +### 10.1 The research pivot + +Before building the agent piece, we verified (primary-source web research, four +independent passes + adversarial check) whether goose/cline/roo actually lack +native skill discovery. **They don't — every supported agent natively +auto-discovers SKILL.md skills:** + +| Agent | Native dir (project) | Notes | +|---|---|---| +| claude | `.claude/skills` | on by default | +| codex | `.agents/skills` | project-local (repo-root), on by default | +| goose | `.agents/skills` | built-in extension, on by default (also reads `.goose/`, `.claude/`) | +| cline | `.cline/skills` | **opt-in** — Settings → Features → Enable Skills (v3.48, Jan 2026) | +| roo | `.roo/skills` | v3.38 (May 2026); the "Roo shut down" rumor was false | + +**Consequence: tier 3 does not exist.** With no non-native harness there is +nothing to emit a disclosure block *for*. So we did **not** build: the +`` block, the sidecar, the `native_skills` boolean, the budget +threshold, or `disclosure_block()`. And the **recency queue was dropped** — its +sole rationale was the disclosure↔search double-exposure problem, which evaporates +without disclosure. `search` is now stateless. + +### 10.2 Final architecture (two concerns, cleanly split) + +**Materialization** (agent layer) — `AgentSetup.setup_skills(working_dir, config)` +mirrors installed (bundled + project) skills into each agent's `native_skills_dir` +via the manifest-tracked `_mirror_skills_to`. Called **once centrally** in +`agents/__init__.py:dynamic_agent_record` (covers all agents, BYOA + proxy). Each +agent declares `native_skills_dir` (class attr); gated by `skills.populate_native` +(default true). Codex/goose use the cross-agent `.agents/skills` standard. + +**Discovery** (`src/dsagt/skill_discovery.py:SkillRouter`) — stateless: +- `search(query, top_k, tag, skill_name)` — catalog tier + no-embedder keyword + fallback (`skill_keyword.py`, a faithful Genesis port). Installed skills are + natively advertised by every agent, so the catalog (not-yet-installed skills) + is the router's irreplaceable job; the keyword path also covers the + Cline-skills-disabled case. +- `list_sources()` — consolidated synced/indexed view. +- Three thin call sites: MCP `search_skills`, CLI `skills search/list`, + `knowledge_server.list_skill_sources`. + +### 10.3 What shipped + +- `src/dsagt/skill_keyword.py` — Genesis-faithful token-overlap scorer (verified + against upstream source: weights, `elif` bonus tiers, stopwords, tokenizer). +- `src/dsagt/skill_discovery.py` — stateless `SkillRouter`. +- `agents/base.py` — `native_skills_dir` ClassVar + `setup_skills`; called in + `dynamic_agent_record`. `native_skills_dir` set on all five agents; claude's + inline mirror removed (now central). +- Call sites rewired: `registry_server` (search), `cli.py` (search/list), + `knowledge_server` (list_sources). +- **License/NOTICE capture on install (done).** `install_into_project` → + `_capture_attribution`: copytree carries skill-local files; ancestor dirs up to + the cache repo root are walked for `LICENSE` / `NOTICE` / `COPYING` / + `ATTRIBUTION` (clone_github mirrors repo-root files into the cache even for + sparse `subdir` clones), nearest wins, and a `PROVENANCE.txt` is stamped. + `install_skill` surfaces what was preserved. +- **Struck the stale `datacard-generator` shipped skill (done).** Verified first + via the GitLab API + raw SKILL.md that Genesis's copy + (`skills/modcon-skills/datacard-generator`, name `generating-datacards`) has + well-formed frontmatter (not a malformed skip). Removed the dir; `skill-creator` + is now the only bundled skill. README/docs point users to + `dsagt skills add genesis` for it. +- Tests: `test_skill_discovery.py` (15), plus `setup_skills` and + attribution-capture coverage in `test_skills_catalog.py`. **228 passed / + 13 skipped** across all affected suites; ruff + black clean. diff --git a/design-notes/skills-catalog-server-merge.md b/design-notes/skills-catalog-server-merge.md new file mode 100644 index 0000000..a058d06 --- /dev/null +++ b/design-notes/skills-catalog-server-merge.md @@ -0,0 +1,177 @@ +# Design Note — SkillsCatalog encapsulation + MCP server merge + +**Status:** shipped — checklist items 1–5 done; only the deferred trace-driven +tool audit (item 6) remains. The implementation/refactor plan that follows from +`genesis-skills-comparison.md` §10 (the locked skills-routing model) and the +`latex/skills-routing.png` diagram. See §5 for what's left. + +**Date:** 2026-06-23 + +--- + +## 0. Where this comes from + +`genesis-skills-comparison.md` settled the *conceptual* model (catalog tier vs +installed tier; `SkillRouter` as the single skill-MCP hub; Skills Catalog +abstracting chroma+cache; Skill Directory for installed+created; `skill-creator` +for authoring). This note is the *engineering* plan to make the code match, in +three parts done in sequence. + +Already shipped (Steps A + B, tested): +- **Frontmatter-only catalog indexing** — `index_catalog` embeds `name + + description + tags`, not the SKILL.md body (progressive-disclosure level 1; + avoids embedder truncation + signal dilution; consistent with the keyword + scorer). Description also stored in metadata for clean search summaries. +- **Catalog-only retrieval** — `SkillRouter` searches only `skills_catalog__*` + collections; the keyword fallback scans only the clone cache. Installed skills + are no longer search candidates (they're natively discovered). + +--- + +## 1. Part 1 — `SkillsCatalog` module + B2 cleanup + +**Goal:** encapsulate all catalog logic behind one module; stop the dead +installed-skill indexing. + +- **`SkillsCatalog`** (new module) — *composition over `KnowledgeBase`*, NOT a + new KB and NOT a copy of the vector primitives. It holds a `KnowledgeBase` + handle (the host server's existing instance → shared embedder, no second model + load) + the clone-cache dir, and exposes the skill-domain API: + - `sync(source, *, force)` — clone + frontmatter-index into `skills_catalog__` + - `search(query, *, top_k, tag)` — ChromaDB over catalog collections, keyword + fallback over the cache when no embedder + - `install(name, project_dir)` — copy a catalog skill into the project (+ the + license/PROVENANCE capture already implemented) + - `list_sources()` — KNOWN_SOURCES + synced/indexed view + - The skill-specific behavior (frontmatter indexing, keyword fallback, a skill + `CollectionRoute` preset) lives here; the vector store + embedder are the + shared KB. ``SkillRouter`` becomes a thin render/MCP facade over it. +- **B2 cleanup** — drop the now-dead indexing into the `skills` collection + (`SkillRegistry._index_skill` / `save_skill` / `reindex_all`, and the + `install_skill` re-index). The router no longer searches `skills`, so this is + wasted embed work. (Touches a few `TestSaveSkill` / `test_reindex_all` + assertions.) + +Server-agnostic: `SkillsCatalog` takes whatever KB it's handed, so it drops +straight into the merged server in Part 2 with no rework. + +--- + +## 2. Part 2 — merge the two MCP servers + +**Why:** both `registry_server` and `knowledge_server` already construct their own +`KnowledgeBase` (`registry_server.py:881` + knowledge's `main()`), so the split +is pure duplication — two embedders, two Chroma accesses, and a write-here/ +read-there hazard on `skills_catalog__*` (sync in knowledge, search in registry). +The two-process split buys little isolation: the heavy/risky work is already +offloaded (`run_command` → `dsagt-run` subprocess; `kb_ingest` → job thread). + +Principle: **process = deployment unit; module = concern boundary.** Merge into +one server; keep `KnowledgeBase` / `ToolRegistry` / `SkillRegistry` / +`SkillsCatalog` / provenance as distinct modules behind one tool-dispatch shell. + +**Migration surface (the real cost):** +- Entry points: `dsagt-registry-server` + `dsagt-knowledge-server` → one + `dsagt-server` (`pyproject [project.scripts]`). +- **Per-agent MCP config generation** — every agent writes *two* server entries + (`dsagt-registry`, `dsagt-knowledge`) via `_build_mcp_servers_dict` / + `_mcp_server_args`; collapse to one across all five agents. +- Backward compat: existing projects' configs reference two servers; `dsagt + start` regenerates to one (write_dynamic overwrite handles it — verify). +- Tests: `test_registry_server`, `test_knowledge_server`, `test_config` (agent + config gen), `test_server_startup`. + +**Ups:** one embedder / one Chroma owner / no cross-process collection hazard; +one MCP server per agent (simpler config, faster startup, one `init_tracing`). +**Downs:** all tools share one process (isolation loss — bounded by the +offloading above); the migration churn above. + +--- + +## 3. Part 3 — tool-surface audit (SEPARATE, evidence-based, later) + +Today: **23 MCP tools** (11 registry + 12 knowledge). The agent already sees all +23 (connects to both servers), so the merge is *neutral* on count — proliferation +is pre-existing. + +- **Do NOT collapse into `mode=`/`action=` mega-tools.** A union-schema tool with + a mode enum is harder for a model than distinct, well-named tools (it must pick + tool *and* mode). Clear names are the discovery signal. +- **Do a trace-driven prune.** Every tool call is in MLflow — audit which tools + actually get used across real sessions, then remove/defer dead weight (likely + suspects: `kb_add_vector_db`, `kb_get_suggestions`/`kb_dismiss_suggestion`, + `kb_job_status`, `reconstruct_pipeline`, maybe `kb_append`). +- Treat as a deliberate pass *after* the merge — not guessed now. + +--- + +## 4. Sequence / checklist + +1. [x] `SkillRouter` owns the catalog (compose KB + cache). + - [x] catalog-only search + cache-only keyword fallback (Step B). + - [x] frontmatter-only catalog indexing (Step A). + - [x] `search()` no longer requires a skill registry (catalog needs only KB). + - [x] add `SkillRouter.sync(source)` (→ `sync_source`) + `SkillRouter.install(name, dir)` + (→ `install_into_project`) so the router owns all four ops. + - [x] wire `install_skill` (registry_server) → `router.install`; + `add_skill_source` (knowledge_server) → `router.sync`. +2. [x] B2: drop dead `skills`-collection indexing — removed + `SkillRegistry._index_skill` / `reindex_all` + the `save_skill` index call + + the `install_skill` re-index block. **Also** dropped the bundled-skill + indexing in `setup_core_kb` (same dead `skills` collection — full cut, not + in the original enumerated list but the same waste). `SKILLS_COLLECTION` + kept as a back-compat name only (no reader, no writer). `TestSaveSkill` + assertions were already file-based (only docstrings mentioned indexing); + `test_reindex_all` is `ToolRegistry`, untouched. All four skill suites + + `test_setup_core_kb` green; ruff + black clean. +2b. [x] **`SkillsCatalog` extraction** (folded into this pass). New + `SkillsCatalog(kb, cache_dir)` class in `commands/skills_catalog.py` owns + the catalog data plane — `sync` / `install` / `search(→list[dict])` / + `list_sources` + backend selection (ChromaDB vs keyword fallback). + `SkillRouter` is now a thin render/MCP facade holding a `SkillsCatalog` + (built from `kb`/`cache_dir`, or injected via `catalog=` so the server + shares one). All existing `SkillRouter(kb=…, skill_registry=…)` call sites + + tests unchanged. +3. [x] Merge servers → one `create_dsagt_server` + `dsagt-server` entry point. + Extracted `_registry_tools_and_handlers` / `_knowledge_tools_and_handlers`; + `create_*_server` kept as thin test-facing wrappers; `dsagt_server.py` + composes both `(tools, handlers)` under one `Server("dsagt")` with a + type-dispatched `call_tool` (registry str passthrough + knowledge + dict→json + error wrap) and one shared-KB `main()` (the cross-backend + guard now lives once, in `_build_kb_from_config`). Old per-server `main()`s + deleted; their KB-None registry path is gone (merged server requires a KB, + fails fast on misconfigured `api`). +4. [x] Collapse per-agent MCP config to one `dsagt` entry — `base.py` + (`_mcp_server_args()` no-arg, flat `_DSAGT_MCP_ALWAYS_ALLOW`, + `_build_mcp_servers_dict`), `claude` / `goose` (incl. `--with-extension` + in `interactive_command` + `run_script`) / `codex` / `cline` / + `opencode` (BYOA + proxy). `roo` rides `_build_mcp_servers_dict`. `info.py` + span-source buckets + module docstrings updated. +5. [x] Tests + backward-compat. 10 config-shape tests + `test_info` + smoke-test + comments updated to the single-server shape; new `test_dsagt_server.py` + (23-tool composition + both return-type contracts). Compat is + **rebuild-not-migrate** (README upgrade note + cline `.cline-data` caveat); + no migration code. 338 passed / 13 skipped across affected suites; entry + point re-registered via `uv sync`. +6. [ ] (later) trace-driven tool-surface audit. + +## 5. Current state + +**Checklist items 1–5 complete** (this work spanned two sessions). The skills +refactor + server merge has shipped: one `dsagt-server` process, one shared +`KnowledgeBase`, one MCP entry per agent, `SkillsCatalog` owning the catalog data +plane behind a thin `SkillRouter` facade, and the dead `skills`-collection +indexing fully removed. Backward compat is rebuild-not-migrate (README §"MCP +Server" upgrade note). 338 passed / 13 skipped across affected unit suites; +ruff + black clean; `dsagt-server` re-registered. + +**Only item 6 remains — the trace-driven tool-surface audit (§3), deliberately +deferred.** It needs real MLflow traces across sessions to decide which of the 23 +tools are dead weight; it is *not* a guess-now task. Pick it up when there's +trace data to mine. Likely first suspects (from §3): `kb_add_vector_db`, +`kb_get_suggestions`/`kb_dismiss_suggestion`, `kb_job_status`, +`reconstruct_pipeline`, maybe `kb_append`. + +**Key context to re-load in a fresh session:** this note + `genesis-skills-comparison.md` +§7–§10 (the locked model) + the diagram. The long diagram-iteration history is not +needed. diff --git a/docs/assets/architecture.png b/docs/assets/architecture.png index 7dbdf467fdd6e38f14da3d03684cad9db43b051e..65f31a7148ae64ff5848170bec387fa701df5cb3 100644 GIT binary patch literal 119777 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%0D7Jbq4YOsUzaT>7tL`0)aqx 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 literal 229608 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

B+_}X^RCn*=CAy)(^2*}v6V4r?QV)eOklNV?( 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�eKS7>Sdce|}Zs^SH{<_p4GoK;F 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/skills/`. Each skill ### Bundled Skills -DSAgt ships a `datacard-generator` skill in `src/dsagt/skills/` with reference templates for generating dataset documentation. It is indexed into the global Skills collection by `dsagt setup-kb`. +DSAgt ships a `skill-creator` skill in `src/dsagt/skills/` (for scaffolding new SKILL.md skills). Bundled and installed skills are **not** indexed for search — every supported agent natively auto-discovers `SKILL.md` folders, so `search_skills` is reserved for the *catalog* tier (skills you can install but haven't yet). Domain skills — including the MODCON `datacard-generator` — are sourced from external catalogs (`dsagt skills add genesis`) rather than bundled, so they stay current upstream. ### Adding Skills -Place a new directory under `/skills/` with a `SKILL.md` describing the workflow. The knowledge server indexes it automatically on next startup, or trigger a re-index via `kb_ingest`. +Place a new directory under `/skills/` with a `SKILL.md` describing the workflow. The next `dsagt start` mirrors it into the agent's native skill directory (e.g. `.claude/skills/`), after which the agent auto-discovers and invokes it — no indexing step. diff --git a/latex/architecture.png b/latex/architecture.png index 7dbdf467fdd6e38f14da3d03684cad9db43b051e..65f31a7148ae64ff5848170bec387fa701df5cb3 100644 GIT binary patch literal 119777 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%0D7Jbq4YOsUzaT>7tL`0)aqx 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 literal 229608 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

B+_}X^RCn*=CAy)(^2*}v6V4r?QV)eOklNV?( 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�eKS7>Sdce|}Zs^SH{<_p4GoK;F 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 MCP servers (symmetric forks; bidirectional because MCP is % request/response — the server replies travel back to the agent). \draw[bilink] (agent.south) -- ++(0,-0.3) -| (knowledge.north); \draw[bilink] (agent.south) -- ++(0,-0.3) -| (registry.north); -% MCP label centered below the agent, between the two MCP-server forks. -\node[lbl] at (1.3,-1.2) {MCP}; +% MCP label in the center of the merged-server box, in the gap between the +% Knowledge and Registry modules — the bridge that joins them into one server. +\node[lbl] at (1.3,-2) {MCP}; % Agent -> MLflow + dsagt-run (fork off the same trunk as the MCP servers, % drop vertically directly above each box). @@ -383,7 +393,7 @@ \section{Architecture} % on every registered-tool invocation. \node[art] (explicit) at (-2.0,-4) {Explicit\\Memory}; \node[art] (domain) at (0.2,-4) {Domain\\Knowledge}; -\node[art] (skills) at (2.4,-4) {Skills}; +\node[art] (skills) at (2.4,-4) {Skills\\Catalog}; \node[art] (tools) at (4.6,-4) {Tool\\Specs}; \node[art] (episodic) at (6.8,-4) {Episodic\\Memory}; \node[art] (history) at (9.0,-4) {Tool\\Records}; diff --git a/latex/skills-routing.png b/latex/skills-routing.png new file mode 100644 index 0000000000000000000000000000000000000000..15c486d469fcf7e8890d94a5d1afb4306646700e GIT binary patch literal 89524 zcmc$`cRber`!=qvs1#8ur9yVuTUNu$%%)_|%xn#zBudC0*)!P{iiBilE3#Mi-uH3! z{@#!KcmMwXJ$~2Y^LZz6U9Z>kc|OncIF9o;Ur!a}uN^!X)CDT+6xtm`?Ht$<@0SN3>KZm>m@r^~K1@Ql!y1KjH?#okBRc-Y@$$KkXH!3EkclZgrR)NLP_h4^tZ<7&g5d2OqQKd+pDQYMWo~BfCm}g0Y!X%8Xbcen@WQCHV>KkY}yNpS{~ z&e~A+)GEi)lUq81r^V%Ut=isecm+!M9E$r=#%Nq0N$XWadZI=C4vDyLNz_3ti6b*h)-Ledwfc|{5%d~?_lii~_r@yBvCx5T4on{PG_AYycNxSmhoGTkP`0(S)Q@;gsBqby&wl>$hJ3DX4$oN^htjzQw zwh|H(nVFgW{r#y})MqBL%eLlf+5KgP=U0u4jMTDqCx0e4v~SJs-~H(Q`}f#D`yb`1qZ|r?3!zn9n_>Us1XC z;?(L)pM;&(`r^dM@bHrA25r>9(%a|HWwUilh-tgJx@>K2Rh|VAUdjpzfl*O9l9FDD ziFCoM$uS--v;BMAa}3PQKRQg@^7P!MmpOFcz*>XnmfhCIYKiNz$G0CZu=Wo2_J7Z2 za_N8d{r+7VdBn|A^eJ{cHItIjD8&FcyLx4N=+?cJRlL&6TYFzPB)B;{yGfGNIlJn0`3*HhFi<>Rw=jYed)Qpaf4h{~Ev2hO$52q$4|7jDh z-;$!3d^5(XJxsckk#uiGRaJ6)y#CK5ITh(!w{BTkv4z$itK+fk%A>zu<~Z3kSQCUZ zafF&WJUV(S_1a-l(hZ-*?SAs_Un?t9Q&Yz#CJLQvzJ2@l@ZrO&SFesH;)aN#_GcN0 zgnjt1_W9wi{j{h80s;am=OXHOI5|&pnKTyJ3~1`LgqE;wv;Ojk6mefmxDkW^;jtf8 zxN)QA%j127^_%IdK)MPJ3_R;~T}Ebmw(5-keIe($!M~lkt_!1aZ{H3~Pm3ax(Z4-; zm_{fiJ>Aa6<_Pg_wJhzXmX=6SPY(r!M${^EGqaRR7MF$5rt-SUj*bq*Cj!9K++60y z4S&RTkNx)6=GU*1rlzJ;R8)3$cH-4288M=sx+*GpwnN`jQc|#0`^o8vcR#{e{9awn z#l^KS-s(3Vggps;^-BNJ@fR;%)YR0xeEE`&j*j_OMl1={fhV{o-xn`%BnonJP6#@( zw4PN7#y#FrQ!_R<7sQ>q4Mlv_&!Lx(bi%Fbd~`&vTb}Gzla~HoQBl#}9=cVu?;v%{ z+iL;e)^Ti>5r0@-#Imr*Mp1UQ^?(qP#32$AWfc{BJ3GH;&s?@PigR=AdQ07R?cCXx zp>fyJQf@J8$F995rrd%u*YDlCXJuuD0E`iF-@WsppP!78(J+>}Ax1PiAwe?ul3B|| z7m8+N6mfBJX=!P!*kydn?9G1K!Qr|H{36myiOb>#hlvmrrdYAZqt;@0Q$r zVq>M1KLTd+U85*QL+mW-m zzI@-_J`zDq*n&*W#f?=vCTj6L2^X+qbaZ3BSMDNwk+!2ajqBeV);@p!JcL#2s$KwU zpAbF05fT}IlSZD&5r+t!vt*Lju4QZHk0U}+6p+B;*jzX{Idyb&?oW2nQ5;A;nx$7J zV%;Nj$VXxi<5X`Mj*;eCk(87a0;R97kA#Zt7nU)M)7WEejKSGS2bLW$p(+gxhG zH*enL=Pxd{8_CklXX54#e%9F9-hTMBfXa;}bxK^gLu)suK2DpNnTd2+ zA$7*Z#o?D(2UD}NQ9bV8yQlu{_QT}l5fq|mA?Iekh%0B`@Y(cZ>22-oQnR)I0t{FrDXBdUCqLnBZ^npN@T_B}u`IImxiX<_ zZEbC=ze2LJvPzxjjewIP>Kq*$66tHK=ZrqSe?M2@a~QjppO>etqx1IdTbw!szX)Q! zX(H@e08NR}qZQk3Yqxj8_eqv8vTi-@hE;b9(|e#!Ia&)>Y+ zuxsC;8K+N8Z?6GSv$C?{JOGN(Gcfpgdp9qKv9hov$we4hTi+zU%*z|YZ#SGM6FNEo zRHuIH*3w9fXHt)*hKAz6Qnt>=0t9}1{5c#1@oFqNHfp3kO6DfTPPrvEv*A4`iG|j^ zxalpRcAWJOZmaKOV)U+G_bn(W7}aHvk5tFOSVaC=4DJV#Cp<4XYaLrZi_U%8vitWa%B7=jah%fUHULheN1$lWCtbM4W z5j>XP>+6$5)}~9>t?DEBr>3TIa&lNG%@JTo#e_nQ?B#N9+&R&wNWG^f%2;QG+X>BcWTjk*A50n1eo_%I9{@uHKRWD9Y z_msT#xMyr&@cb0N?b^av3)6!Z%T<7+@n{Bjt*yEU-XMmXOY`#)C}23?r%#_|*ZI(r zYkYIuX#L@?eFg>wc>5QCqv7En>_+4tUU_HTD}tj75U8fsjzl3J#xaB{fEu54K*)CR zI#v#qVfW52^x<%<*to|IEsJ>adWtFy$SuP;s7 zA7>N4VG^N$KePHg1S+ipa~)#}PbIY+^Xw+}f&B zXoXZW;U#{@#AFnE3drg?{Nsdx1c(6+_!zffdU|^57Cm(_*`%iZ^xE@re;VPgwzdKk z_d|ycVP$%9?p`Aa+uYn-Ul@~k!g>+kWn^STia0Ie#;1DlZad2<|gAxkJy?bvs44?V<_?UPDM&m&H zAHOVaq28Fxc8idmi1hXdvCgeJ3F%rAUaStQw1Aophz6uRyhRJ&Hlj}tCA1|~xy5cb zQWh#|i>0%gnwlBqE|C~fhw&CHUGxW)ce%L{Kt`{s8Dj#be7Zr6~`upzPJ0zbnq*fe!tWS1YT3S{X8#_BMmTdiuj7;sqf_>Az zmmloM&fR*A^7`@PI;t-*ZF{!f8BQZvyM1+ab;k}oVPazP=XGD15!2G@mi-zfcwgl1 ztewNF4{qa?m`A7 z#dzlE?s)x@DO3bh)3&ydDOQ|y3V&a2Mp|028#l(@-fYcL&lnvw(bLo8=FZ1kGhJ4`kS;2My9@s^a-6PMjla{~cj zW&;7JZbyzBdGh25^4Fd{dwQ$)sVFNCjEoeT{!GHDIU-I?O%1|%t$|KV5xJwWk*wr7 zn8P4qCpG^PBks5tM+@0iYI?2Ib(!tbr5XGMG!1WmiIbCDP{2LLZL%vL%+%t&H%nDG@)cig&KlbN(&|^V}Kfi%Vfw7Tzq6Hk% zij6K`zO1OAK&Ev!v8YI0PA(Hf207_(Pl=#T>6Nn=85!e3BK9cS*x2B}yUhIAu~?9h zaDj#8`LmZzE=zwIDQO}22)M81<>hfjy$87^?4^K%S#1cbeLR39Smw^1JHgFSyT`1T z85Y@~QpiQ{C_YSn_wJIhu`#yGrD&h3xQdF3i_01|A~Q1+_?PK};4#@FI*(K`+83vL zV;V*{!(_+TrQ(uqnTnYY4`1*i&{sM-!`3v_^jn$z^Q-#~o$AOnZUEaFBLy^2RaA7| z-V)}1Y&xJ$L)i5HkO_}P$CZ-n*RRJW=b!fiMKEuQm%@!HfmE$hM{HONc;i6+T)u>x zSnIDOX^NEFx?xZZf@TBFgA$W0|3y$pEdkVbo3Ms;NakewMJKa z`@GRcck$~^PEI(Dp!d=xz+InQ7MZx?h~%WD&49!`Hy1-kTRo7P@71X;^Anti+e$W$iHV6lJ9p#@umsgC%+B(ImD<=`&gbUk4Gk}|8~ElA{1Bi1d8#wl zC+k~bYE91~;szl$-CfvFQ}a|_2nfc)BEzJ>ygf@UMnorXeg6~jrgwX2zkdJzy{&EO z>r?Xh7*k0}NgJD-+}wFwe|lQl*EECMx337Nyb1|P%F61|t^;5mJwlEPM~Vi4(5HjE zso>Y-9u0F54rG*79D=j@;%DTYscg03R7{&O+#P46fK7IOx z(;|2J#fK1iX=#Y3iR|rVW;kqa3!_J92^1H63m_K2=F_LhN`3szpPE~mD>p1mLr$&^ z1Zywp@z~VVu)sjFBS*xlINaRb0Xjp2gHibHr+b85Zuwbu7yK@D*Af%EGdEa+{G8lo z@1QNTGked-hr72>(jZPnJ)EJTEQX%DGzw*oI{|cm8~#MW?vv zVNk~qAWt&!t*v!yGs>I7!X+aOG1*#$Q%K49Fl26sAqziMTR!j~J$e+Fb8&8N9O;l& zQe30&Wl_K;B*~$f!Z~)hh(??;@MG#l`$F4TldO zPG%yfrbbw$r=;)!Sh0=Yp%;LssLkP8rh8>!oD%c_qN1uRkFEo9txDAKxp*9mG4 zVp@n|)|*O6uglBD5sXNYO+kE0&@=l$XTrm!57wZrWI-cE=BG6Pxh9W{aO2pqV*nY*=803PRz06qu{t=FEwWCy4lDtmw<_R_a;`O3O-023u<-h>rGUlb-5xPgj#mpLc=t7%xRe(xLh!|ox3#oD6aXIi zf#JN@nv*0&9Ox?RVAf!WW<_9**)qG4h|D%rmE}eP(aXyp_0i?O^r)ync610 zeB3K=Ia}i3qC@v=%xh-eWHxPK!|$)>N4h6sUO#(wVE_L8d-go(@9zf_yQQL{uBq89 zQ{qMtdaTPKqn{;v9UKgJX$Hti#ikuwmh2`X%E<{KM$KaPwSk^sNX(6mvFYi| zZ4JQB{HI<3l&QPO$sP2mXlie_-dujvFfuYY2=L^_b>Xtp%pZs$3J(JUy!}_)N)r+c zjEs^#eG=LXBq2nITg0W|{vae7RxYmBWVW;Ym9JhM9j|i1DMtmltFPYzje?5{NFo1T zU-|3TuhC;taD_~UCnk(1L{9LJw1Y@BhOSBN<~^4Q$Up@>gGB zz#;0gd2^ACC*rrZs40g&8W!faZ_nH~X2cPe*+~a9Brl)EeTQWHaI%rZYRfwg!b;jp zAGe$3zsuI8U{^R4@$tl#Gt`ex3m)z&1_y?M#dXJ|%T$($DJdqyKf=I}QDert^34?$ zn~-q9UTnr&7@Il|^bKqiIfURRFG8;xYfj8Hs~nJY8$b4pV!`gZ-=y0@@rmwA9XrDlva&oN17PE-tE<5~(^67M zPjGledlUVM8SO5d2A&1#0wV*h13rVOCM9aP6nvC;H%K?AZpFyIFp1gmlH@^yfteZ8 z2PdX0T6ZoTf4ui#d`Ru&DO5(ZAgLP$|BB#%D%X zk7hvLBBl+!sbkgtmST`HHnmI)DuGvE_j31g?bh-~S#e?`=Z*K8Z_+3U%Nc8U`m(K9 zotcLEn=_|Rw|QJEcAO-oHTeFTu_)TK6DWq5_73fx$|seR_g~BT@g5PEq#Ax;{bg-& zVn3}&8I4t$1OTP=Z-)nD9Hz||{+}JO!J(m0sDi{0QxpVG$A9=>)?1qCMFx6n|0o1P zr4sKq&)zLp!PDmi&kOR4gps{BX*$QX!uv$Ci9SZzez{L?(%$#vvN$oV1y6TR56!`U zsdk22-~LD9DUhZVZfY*B4m9zw+~*nEc^d2ujEw=my$@);>->wOXa6Vwb@DBx3s)|e z*2u@HkA1z0=3ln#YWCOoxF7EQbPtkJfBziXDS1nUMyqo}$h>F0WKW2Gx9j0@6^!6v z45=C@aXVJ%RM)n`%jgw|`s-fviq6E6=z+By3Ymey1=_DD<*lvxHr`*qenppqE0mUn z1%SkubnoLz{maKmA>27QEP_ZWEO-1Pb5uI{;bi6dIY5`t1}-rO5M3Dw(Dv1B$Rp>8 za3A`9?3cIYkklWnJezh=qleGCygVV$3TP%xUA^HbB2-kND=RBYf?j@pwP+SyetVGU zGw=0_6lf|~^_J@B>TUsW=xYH6@>unhuDP9m3s~I{T(h&Y z;4#EnZV|W|>;gdH&E7IIiJqRGQ-?-STZpv?N%J`AH|(yqwl?1>d3X1cjy8xyW{H;| z={3ER5*IHAu)(Gk=~o_LvJfM_lcNrd;2#h`wP^}j8*(M|jJFvXEU1&`&qFccMEQoi z2D%=2H(Fg!Te_|osghYt`HP};Y!^bj5>>ZV6Vs`PWd z9yJBbi|-3So9srvS%(L(GUesOJWtwk$N0arv#L6L`k zqSK_R~5# zI%cwnva!jdZ~)nQk%`2x)6zn@w_6xhWMSD!)L1}POnAEo^^9z6R*+7-$WEV*f}DAj z(8NPKB!o>zTC$%yufJb^?_;rpKTV!j^qH8MHCw0NU;|nQd3=_%m&ioTR~zy48*&r+ zH9?3g@1!L9h&n6V_#$v*N>FUzNnLsxP#0DIaxZgdQ48T1ragBlE$pU!y-FD<_u- zd^)e-c{->j!I6r1H?-<_@+lQHHMGrfL;%i+pNK!=Thr(|dMf|$g_oQY~4yZ}qigK9*Z zFR(?4!vrPm1$ugKvUBY0)@EkXI`VeKzaSU^w?Z9)KKL8?$i50fU3+V5Vp5Wl+6pkJ zL5@1KXO+QoLP8tWFVACH@WZ^1zo5alw7ksA!-KxgR8Psrj$onC+JmH|pYBgbqj18j z0NQZntP^$_T;bQRU;S{-3=HUKYpb(RmdHR-!6qmxDee6yFWeNbCgkI>W;)POXaa|+ zxqR!L@liM+Fq$yEZcM&;1}UJaT1rEs6D=FS1mHM1ykupc`}<3Xiz6-R{(H$20|Jn! z8b7=Og+^0VRcdk_0BT}vjJw_2O{5GNL{N}`x^SfNnZn#@8_4j*#l`kNkwqOR+Ug*m zaoK_*ETA6^XoYl(7y;CWt!ak6_WSpE<5*M)^d0couV1}_XiMj%5TvcJz17IV!UAC$ z2pJ9WN@++G;SmuLCem(hpAa@-VJE_=iDFAGdx_I(?jVXKKMv8I_AAx~El+u9&q$(3 zki+-*B)myAO5KV(R{WsopzCr}99D<#M!BhkA3nx>iy*bbL1AAH76_?(WN2ve_s90? zYG3&PUtd0_nX{p_5PMfpx};)`H#9W#7TYo8zjwHO`|RoBB6K_;NocUNf%Z#Us|}Bg zKrcPd%v@VvZv~(quc}k#;VvzGfYtz76ZCS+jgf5ioOozIG9fHSr1JlxsKkUHk;bt)WCx$dC0^ar+z&pBnNACt=&YioNxCsqWLL-!(J1_fX29Koi4N? zH3^BwPoF+D0f1S9lJM0Q$#rdY)uvdRfgu@96F+ZO0zz@co#Pb8KgGSE$Ma`?6=y*I zZ3ZMv5LKLg-BLGac!rwfPzqvo(fG@^c>;MmI4q3cV`JqOk6%H-QaHCc(A50=yl6NS z1kb_hmtKJ&^@NVL{Re;lgAThws3jmqFgVU)^}CBIiT>y*pz#hK4S5JO7tGF!46P|c zOG{zn`cw2lVPRp#=#c&S^9P_F3fA1JTa!Cry~W+TxfY%8Px4wpI?^ulh&mwm%$v2m zq5@kT1zfML?)10g@-O1uSRE%#tj;`$QDI@BC-eo}G~6f4P5Eo6eEVr}TBH2*I7L^4zr>-KBqgJ@&(g@C|G4 zdh{2t{E%0=Dk|<)2h!??q46LjEWG@;Q-YY^cF-3(eoRbERMcng_*BH{a_QPAER5=h zG0M^tJPgo>#p5nryl4Vk?-dB}T3vnJ+Il)9V0~-T)hsc%ZDSP`9{!87UZCD!4(;vj zfXu+Cesy1N7o@*OhM;DIhdDEDAc_vhCfw7YmZSod+qw(7*r! zE<6=za&T*GA?cx7!fgpC@;#V2!jP}g#>T)ex5>nD?!qJdx6%4( z@%zU46#ky))ZEX=fOF8x(AC!77_1?8>3rGtuAl&9I;j_xzYtBfCmV1z5Zd'u5t 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[length=2mm]}, + box/.style={ + draw, rounded corners=2pt, thick, + minimum height=12mm, align=center, + font=\footnotesize, fill=white}, + ext/.style={box, fill=blue!12}, % external (repos, agent) + svc/.style={box, fill=green!12}, % DSAgt service + store/.style={box, fill=orange!14}, % DSAgt store / skill + skill/.style={draw, rounded corners=2pt, thick, fill=white, + font=\scriptsize, minimum height=5mm, align=center}, + link/.style={->, thick, blue!60!black}, + bilink/.style={<->, thick, blue!60!black}, + lbl/.style={font=\scriptsize\itshape, text=gray!55!black, align=center}, + concern/.style={font=\footnotesize\bfseries, text=gray!62!black}, + band/.style={draw=gray!55, dashed, rounded corners, fill=black!3}, +] + +% ============================ legend ==================================== +\node[ext, minimum width=5mm, minimum height=4mm] (lk1) at (-1.6,7.4) {}; +\node[right=1mm of lk1, font=\scriptsize, anchor=west] (lt1) {External}; +\node[svc, minimum width=5mm, minimum height=4mm, right=14mm of lk1] (lk2) {}; +\node[right=1mm of lk2, font=\scriptsize, anchor=west] {DSAgt service}; +\node[store, minimum width=5mm, minimum height=4mm, right=24mm of lk2] (lk3) {}; +\node[right=1mm of lk3, font=\scriptsize, anchor=west] {DSAgt store / skill}; + +% ============================ external (outside bands) ================== +\node[ext, minimum width=32mm] (extrepos) at (-0.2,0.6) + {External Skills Repos\\{\scriptsize scientific $\cdot$ anthropic $\cdot$ antigravity}\\{\scriptsize composio $\cdot$ genesis $\cdot$ any git URL}}; + +\node[ext, minimum width=22mm] (agent) at (16.8,3.8) {Agent}; + +% ============================ DSAgt stores ============================== +\node[store, minimum width=34mm] (catalog) at (5.6,0.6) + {\textbf{Skills Catalog}\\{\scriptsize federated across sources}\\{\scriptsize searchable $\cdot$ not yet installed}}; + +\node[store, minimum width=46mm, minimum height=26mm] (native) at (11.6,0.9) {}; +\node[anchor=north, align=center, font=\footnotesize] + at ([yshift=-1.6mm]native.north) + {Skill Directory\\{\scriptsize installed + created}\\{\scriptsize .claude $\cdot$ .agents $\cdot$ .cline $\cdot$ .roo /skills}}; +\node[skill, minimum width=26mm, anchor=south] at ([yshift=2mm]native.south) + {\texttt{skill-creator}}; + +% ============================ DSAgt service ============================= +\node[svc, minimum width=42mm] (router) at (5.6,5.8) + {\texttt{SkillRouter}\\{\scriptsize search $\cdot$ keyword fallback $\cdot$ list\_sources}}; + +% ============================ router operations (skill MCP tools) ======= +\draw[link] (router.west) -| (extrepos.north) + node[lbl, pos=0.25, above] {add\_skill\_source\\list\_skill\_sources}; +\draw[bilink] (router.south) -- (catalog.north) + node[lbl, pos=0.5, right] {search\_skills}; +\draw[link] (router.east) -| (native.north) + node[lbl, pos=0.25, above] {install\_skill}; +\draw[bilink] (router.north) -- ++(0,0.5) -| (agent.north) + node[lbl, pos=0.25, above] {MCP}; + +% ============================ agent <-> skill directory (two-way) ======= +\draw[bilink] (native.east) -| (agent.south) + node[lbl, pos=0.22, below, align=center] {auto-invoke\\author}; + +% ============================ concern bands ============================= +\begin{scope}[on background layer] + \node[band, fit=(catalog), inner sep=3mm] (regband) {}; + \node[band, fit=(native), inner sep=3mm] (expband) {}; + \node[band, fit=(router), inner sep=3mm] (disband) {}; +\end{scope} +\node[concern] at (disband.north) [above=0.6mm] {DISCOVERY}; +\node[concern] at (regband.south) [below=0.6mm] {REGISTRATION}; +\node[concern] at (expband.south) [below=0.6mm] {PROGRESSIVE EXPOSURE}; + +\end{tikzpicture} +\end{document} diff --git a/pyproject.toml b/pyproject.toml index c0e68d9..5a19a37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,8 +60,7 @@ dependencies = [ dsagt = "dsagt.commands.cli:main" dsagt-run = "dsagt.commands.run_tool:main" dsagt-proxy = "dsagt.commands.proxy_server:main" -dsagt-registry-server = "dsagt.commands.registry_server:main" -dsagt-knowledge-server = "dsagt.commands.knowledge_server:main" +dsagt-server = "dsagt.commands.dsagt_server:main" dsagt-setup-kb = "dsagt.commands.setup_core_kb:main" [dependency-groups] diff --git a/src/dsagt/agents/__init__.py b/src/dsagt/agents/__init__.py index 2db06de..89fa517 100644 --- a/src/dsagt/agents/__init__.py +++ b/src/dsagt/agents/__init__.py @@ -362,6 +362,9 @@ def dynamic_agent_record( Path(working_dir), Path(config["project_dir"]), ) + # Mirror installed skills into the agent's native skills dir (all agents, + # both modes). Central here so each agent only declares native_skills_dir. + actions += setup.setup_skills(Path(working_dir), config) # Launch shim is BYOA-only. Skip when proxy mode is active — # ``dsagt start --enable-proxy`` is the only sensible entry point # in that mode (proxy URL must be plumbed through agent env). diff --git a/src/dsagt/agents/base.py b/src/dsagt/agents/base.py index 454ac46..b1df791 100644 --- a/src/dsagt/agents/base.py +++ b/src/dsagt/agents/base.py @@ -37,35 +37,33 @@ # sync with ``commands/registry_server.py`` and # ``commands/knowledge_server.py`` tool registrations; a tool added there # but not here means roo/cline will hang on its first call. -_DSAGT_MCP_ALWAYS_ALLOW = { - "registry": [ - "get_registry", - "http_request", - "install_dependencies", - "install_skill", - "read_file", - "reconstruct_pipeline", - "run_command", - "save_skill", - "save_tool_spec", - "search_registry", - "search_skills", - ], - "knowledge": [ - "add_skill_source", - "kb_add_vector_db", - "kb_append", - "kb_dismiss_suggestion", - "kb_get_memories", - "kb_get_suggestions", - "kb_ingest", - "kb_job_status", - "kb_list_collections", - "kb_remember", - "kb_search", - "list_skill_sources", - ], -} +# All dsagt MCP tools (registry + knowledge) now live behind the single +# ``dsagt-server``, so the always-allow list is one flat union. +_DSAGT_MCP_ALWAYS_ALLOW = [ + "add_skill_source", + "get_registry", + "http_request", + "install_dependencies", + "install_skill", + "kb_add_vector_db", + "kb_append", + "kb_dismiss_suggestion", + "kb_get_memories", + "kb_get_suggestions", + "kb_ingest", + "kb_job_status", + "kb_list_collections", + "kb_remember", + "kb_search", + "list_skill_sources", + "read_file", + "reconstruct_pipeline", + "run_command", + "save_skill", + "save_tool_spec", + "search_registry", + "search_skills", +] # Sentinel API key planted in agent / MCP-child env when the optional @@ -135,13 +133,13 @@ def _openai_env(llm: dict) -> dict[str, str]: # --------------------------------------------------------------------------- -def _mcp_server_args(server: str) -> list[str]: - """Build the argv tail for ``uv run dsagt--server``. +def _mcp_server_args() -> list[str]: + """Build the argv tail for ``uv run dsagt-server``. - Both servers read all configuration from ``DSAGT_PROJECT_DIR`` (env) - and dsagt_config.yaml — no CLI args needed. + The single merged server reads all configuration from the project's + ``dsagt_config.yaml`` (located via cwd-walk) — no CLI args needed. """ - return ["run", f"dsagt-{server}-server"] + return ["run", "dsagt-server"] def _mcp_env_block(config: dict) -> dict[str, str]: @@ -294,25 +292,22 @@ def _mirror_skills_to(target_dir: Path, skill_dirs: list[Path]) -> list[str]: def _build_mcp_servers_dict(env_block: dict | None) -> dict: - """Build the standard ``{"mcpServers": {...}}`` dict for dsagt servers. + """Build the standard ``{"mcpServers": {...}}`` dict for the dsagt server. Used by agents that load MCP config from a JSON file (roo via ``.roo/mcp.json``). Claude Code uses the same shape via ``.mcp.json`` but builds it inline in :class:`ClaudeSetup.write_dynamic`. Cline - doesn't use this — it requires ``cline mcp add`` to register servers. + doesn't use this — it requires ``cline mcp add`` to register the server. """ - mcp_config: dict = {"mcpServers": {}} - for server in ("registry", "knowledge"): - entry: dict = { - "command": "uv", - "args": _mcp_server_args(server), - "disabled": False, - "alwaysAllow": _DSAGT_MCP_ALWAYS_ALLOW[server], - } - if env_block: - entry["env"] = env_block - mcp_config["mcpServers"][f"dsagt-{server}"] = entry - return mcp_config + entry: dict = { + "command": "uv", + "args": _mcp_server_args(), + "disabled": False, + "alwaysAllow": _DSAGT_MCP_ALWAYS_ALLOW, + } + if env_block: + entry["env"] = env_block + return {"mcpServers": {"dsagt": entry}} def _toml_quote(value: str) -> str: @@ -553,6 +548,15 @@ def __init__(self, proxy_port: int | None = None): #: See agents/.py docstrings for the per-agent investigation. otel_payload_support: ClassVar[str] = "full" + #: Directory (relative to the working dir) the agent natively auto-discovers + #: ``SKILL.md`` skill folders from. ``setup_skills`` mirrors installed + #: (bundled + project) skills here so the agent discovers/auto-invokes them + #: without an MCP round-trip. Every supported agent has one — claude + #: ``.claude/skills``, codex/goose ``.agents/skills`` (the cross-agent + #: standard), cline ``.cline/skills``, roo ``.roo/skills``. ``None`` would + #: mean the agent has no native skill discovery (none currently). + native_skills_dir: ClassVar[str | None] = None + @abstractmethod def write_static(self, working_dir: Path) -> list[str]: """Write the agent's instructions file + any state directories. @@ -577,6 +581,29 @@ def write_dynamic( Returns a list of one-line action descriptions. """ + def setup_skills(self, working_dir: Path, config: dict) -> list[str]: + """Mirror installed (bundled + project) skills into the agent's native + skills dir so it auto-discovers/auto-invokes them. + + Mode-independent (runs for BYOA and proxy alike) and idempotent — the + manifest-tracked :func:`_mirror_skills_to` only reaps skills dsagt + placed, never user-authored ones. No-op when the agent declares no + ``native_skills_dir`` or ``skills.populate_native`` is disabled. + """ + if not self.native_skills_dir: + return [] + if not (config.get("skills") or {}).get("populate_native", True): + return [] + from dsagt.registry import SkillRegistry + + reg = SkillRegistry(runtime_dir=working_dir, kb=None) + # Bundled first, project last → project wins name collisions. + src_dirs = reg._bundled_skill_dirs() + reg._project_skill_dirs() + target = working_dir + for part in self.native_skills_dir.split("/"): + target = target / part + return _mirror_skills_to(target, src_dirs) + def runtime_env(self, config: dict) -> dict[str, str]: """Dsagt-owned env vars the agent process needs at runtime (BYOA). diff --git a/src/dsagt/agents/claude.py b/src/dsagt/agents/claude.py index 87ad480..1be23ad 100644 --- a/src/dsagt/agents/claude.py +++ b/src/dsagt/agents/claude.py @@ -52,7 +52,6 @@ _load_master_instructions, _mcp_env_block, _mcp_server_args, - _mirror_skills_to, _run_simple_script, ) @@ -83,6 +82,7 @@ class ClaudeSetup(AgentSetup): name = "claude" base_command = ["claude"] static_marker = "CLAUDE.md" + native_skills_dir = ".claude/skills" install_hint = "Install with `npm i -g @anthropic-ai/claude-code`." # Anthropic-protocol native; cross-protocol routing requires the proxy. credential_env_vars = ( @@ -158,28 +158,19 @@ def write_dynamic( actions: list[str] = [] env_block = _mcp_env_block(config) - mcp_config: dict = {"mcpServers": {}} - for server in ("registry", "knowledge"): - entry: dict = {"command": "uv", "args": _mcp_server_args(server)} - if env_block: - entry["env"] = env_block - mcp_config["mcpServers"][f"dsagt-{server}"] = entry + entry: dict = {"command": "uv", "args": _mcp_server_args()} + if env_block: + entry["env"] = env_block + mcp_config: dict = {"mcpServers": {"dsagt": entry}} mcp_path = working_dir / ".mcp.json" mcp_path.write_text(json.dumps(mcp_config, indent=2) + "\n") actions.append(f"Wrote {mcp_path}") - # Mirror installed (project) + bundled skills into Claude Code's - # native skill dir so it discovers/auto-invokes them without an MCP - # round-trip. Bundled first, project last → project wins collisions. - # A newly-created .claude/skills/ is only picked up on Claude restart, - # which is fine: this runs at init/start, before the agent launches. - if (config.get("skills") or {}).get("populate_native", True): - from dsagt.registry import SkillRegistry - - reg = SkillRegistry(runtime_dir=working_dir, kb=None) - src_dirs = reg._bundled_skill_dirs() + reg._project_skill_dirs() - actions += _mirror_skills_to(working_dir / ".claude" / "skills", src_dirs) + # Skills are mirrored into .claude/skills/ centrally via + # AgentSetup.setup_skills (driven by native_skills_dir) in + # dynamic_agent_record — see base.py. Picked up on the next Claude + # start, which is fine: this runs at init/start, before launch. # Configure mlflow autolog claude — writes .claude/settings.json # with the MLflow Stop hook + tracking env vars. Idempotent and diff --git a/src/dsagt/agents/cline.py b/src/dsagt/agents/cline.py index e6fbac5..dd024d4 100644 --- a/src/dsagt/agents/cline.py +++ b/src/dsagt/agents/cline.py @@ -81,6 +81,9 @@ class ClineSetup(AgentSetup): name = "cline" base_command = ["cline"] static_marker = ".clinerules/dsagt_instructions.md" + # Cline skills are opt-in (Settings → Features → Enable Skills); mirroring + # is harmless if unused, and search_skills covers the disabled case. + native_skills_dir = ".cline/skills" install_hint = "Install with `npm i -g cline`." otel_payload_support = "none" # Cline's CLI nominally supports openai-native + anthropic, but cline @@ -90,19 +93,30 @@ class ClineSetup(AgentSetup): # by cline's anthropic SDK at runtime, covering api.anthropic.com and # gateway endpoints alike. Match roo's policy. credential_env_vars = ( - "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_MODEL", + "ANTHROPIC_API_KEY", + "ANTHROPIC_BASE_URL", + "ANTHROPIC_MODEL", ) # Cline emits no OTel — agent-side telemetry only via --proxy_traces. telemetry_env = {} credential_hints = ( - ("ANTHROPIC_API_KEY", "your provider API key (works for openai-shape " - "gateways too — cline's anthropic SDK reaches them via " - "ANTHROPIC_BASE_URL)"), - ("ANTHROPIC_BASE_URL", "gateway / proxy URL " - "(cline auth's -b flag is openai-only; the anthropic SDK reads " - "this env var at runtime)"), - ("ANTHROPIC_MODEL", "model name your gateway serves " - "(e.g. claude-haiku-4-5-20251001-v1-project)"), + ( + "ANTHROPIC_API_KEY", + "your provider API key (works for openai-shape " + "gateways too — cline's anthropic SDK reaches them via " + "ANTHROPIC_BASE_URL)", + ), + ( + "ANTHROPIC_BASE_URL", + "gateway / proxy URL " + "(cline auth's -b flag is openai-only; the anthropic SDK reads " + "this env var at runtime)", + ), + ( + "ANTHROPIC_MODEL", + "model name your gateway serves " + "(e.g. claude-haiku-4-5-20251001-v1-project)", + ), ) def write_static(self, working_dir: Path) -> list[str]: @@ -163,15 +177,22 @@ def write_dynamic( "serve both wire protocols on the same key." ) auth_cmd = [ - "cline", "auth", - "--config", cline_dir, - "-p", "anthropic", - "-k", api_key, - "-m", model, + "cline", + "auth", + "--config", + cline_dir, + "-p", + "anthropic", + "-k", + api_key, + "-m", + model, ] result = subprocess.run( - auth_cmd, cwd=str(working_dir), - capture_output=True, text=True, + auth_cmd, + cwd=str(working_dir), + capture_output=True, + text=True, ) if result.returncode != 0: detail = (result.stderr or result.stdout).strip() @@ -190,30 +211,35 @@ def write_dynamic( existing: set[str] = set() if mcp_path.exists(): try: - existing = set(json.loads(mcp_path.read_text()) - .get("mcpServers", {}).keys()) + existing = set( + json.loads(mcp_path.read_text()).get("mcpServers", {}).keys() + ) except (json.JSONDecodeError, OSError): existing = set() - for server in ("registry", "knowledge"): - name = f"dsagt-{server}" - if name in existing: - continue + if "dsagt" not in existing: add_cmd = [ - "cline", "mcp", "add", - "--config", cline_dir, - name, + "cline", + "mcp", + "add", + "--config", + cline_dir, + "dsagt", "--", - "uv", "run", f"dsagt-{server}-server", + "uv", + "run", + "dsagt-server", ] result = subprocess.run( - add_cmd, cwd=str(working_dir), - capture_output=True, text=True, + add_cmd, + cwd=str(working_dir), + capture_output=True, + text=True, ) if result.returncode != 0: detail = (result.stderr or result.stdout).strip() raise RuntimeError( - f"cline mcp add {name} failed " + f"cline mcp add dsagt failed " f"(exit {result.returncode}): {detail}" ) @@ -244,7 +270,10 @@ def write_dynamic( # we extract them into a helper. def _patch_mcp_servers( - self, config: dict, working_dir: Path, cline_dir: str, + self, + config: dict, + working_dir: Path, + cline_dir: str, ) -> str: """Idempotent ``cline mcp add`` + JSON env-block patch. Returns the action string for the printout. @@ -253,27 +282,35 @@ def _patch_mcp_servers( existing: set[str] = set() if mcp_path.exists(): try: - existing = set(json.loads(mcp_path.read_text()) - .get("mcpServers", {}).keys()) + existing = set( + json.loads(mcp_path.read_text()).get("mcpServers", {}).keys() + ) except (json.JSONDecodeError, OSError): existing = set() - for server in ("registry", "knowledge"): - name = f"dsagt-{server}" - if name in existing: - continue + if "dsagt" not in existing: add_cmd = [ - "cline", "mcp", "add", "--config", cline_dir, name, - "--", "uv", "run", f"dsagt-{server}-server", + "cline", + "mcp", + "add", + "--config", + cline_dir, + "dsagt", + "--", + "uv", + "run", + "dsagt-server", ] result = subprocess.run( - add_cmd, cwd=str(working_dir), - capture_output=True, text=True, + add_cmd, + cwd=str(working_dir), + capture_output=True, + text=True, ) if result.returncode != 0: detail = (result.stderr or result.stdout).strip() raise RuntimeError( - f"cline mcp add {name} failed " + f"cline mcp add dsagt failed " f"(exit {result.returncode}): {detail}" ) @@ -308,6 +345,7 @@ def proxy_write_dynamic( """ del pdir from .base import _PROXY_FORWARDED_SENTINEL + actions: list[str] = [] cline_dir = str(working_dir / ".cline-data") Path(cline_dir).mkdir(parents=True, exist_ok=True) @@ -320,16 +358,25 @@ def proxy_write_dynamic( "config['proxy']['port'] and config['llm']['model']." ) auth_cmd = [ - "cline", "auth", - "--config", cline_dir, - "-p", "openai", - "-k", _PROXY_FORWARDED_SENTINEL, - "-m", model, - "-b", f"http://localhost:{proxy_port}", + "cline", + "auth", + "--config", + cline_dir, + "-p", + "openai", + "-k", + _PROXY_FORWARDED_SENTINEL, + "-m", + model, + "-b", + f"http://localhost:{proxy_port}", ] result = subprocess.run( - auth_cmd, env=env, cwd=str(working_dir), - capture_output=True, text=True, + auth_cmd, + env=env, + cwd=str(working_dir), + capture_output=True, + text=True, ) if result.returncode != 0: detail = (result.stderr or result.stdout).strip() @@ -356,6 +403,7 @@ def launch_oneliner(self, project: str, project_dir: Path) -> str: """ del project import shlex + pdir = shlex.quote(str(project_dir)) cline_dir = shlex.quote(str(project_dir / ".cline-data")) return f"cd {pdir} && cline -v -y -a --config {cline_dir}" diff --git a/src/dsagt/agents/codex.py b/src/dsagt/agents/codex.py index 52ef5b3..506bc66 100644 --- a/src/dsagt/agents/codex.py +++ b/src/dsagt/agents/codex.py @@ -90,23 +90,24 @@ def _render_codex_config(mcp_env: dict) -> str: do land in ``codex.tool_result`` log events with full args + output. """ lines: list[str] = [] - for server in ("registry", "knowledge"): - lines.append(f"[mcp_servers.dsagt-{server}]") - lines.append('command = "uv"') - args = _mcp_server_args(server) - args_toml = ", ".join(_toml_quote(a) for a in args) - lines.append(f"args = [{args_toml}]") - if mcp_env: - lines.append(f"[mcp_servers.dsagt-{server}.env]") - for k, v in mcp_env.items(): - lines.append(f"{k} = {_toml_quote(v)}") - lines.append("") - lines.extend([ - "[otel]", - 'trace_exporter = "otlp-http"', - "log_user_prompt = true", - "", - ]) + lines.append("[mcp_servers.dsagt]") + lines.append('command = "uv"') + args = _mcp_server_args() + args_toml = ", ".join(_toml_quote(a) for a in args) + lines.append(f"args = [{args_toml}]") + if mcp_env: + lines.append("[mcp_servers.dsagt.env]") + for k, v in mcp_env.items(): + lines.append(f"{k} = {_toml_quote(v)}") + lines.append("") + lines.extend( + [ + "[otel]", + 'trace_exporter = "otlp-http"', + "log_user_prompt = true", + "", + ] + ) return "\n".join(lines) @@ -126,16 +127,18 @@ def _render_codex_config_proxy(mcp_env: dict, proxy_port: int, model: str) -> st any table headers in TOML. """ base = _render_codex_config(mcp_env) - header = "\n".join([ - f'model = "{model}"', - 'model_provider = "dsagt-proxy"', - '', - '[model_providers.dsagt-proxy]', - 'name = "DSAGT Proxy"', - f'base_url = "http://localhost:{proxy_port}/v1"', - 'wire_api = "chat"', - '', - ]) + header = "\n".join( + [ + f'model = "{model}"', + 'model_provider = "dsagt-proxy"', + "", + "[model_providers.dsagt-proxy]", + 'name = "DSAGT Proxy"', + f'base_url = "http://localhost:{proxy_port}/v1"', + 'wire_api = "chat"', + "", + ] + ) return header + base @@ -143,9 +146,11 @@ class CodexSetup(AgentSetup): name = "codex" base_command = ["codex"] static_marker = "AGENTS.md" + # Project-local .agents/skills (repo-root, codex-discovered) — never the + # global ~/.agents/skills or ~/.codex; manifest-tracked, user skills safe. + native_skills_dir = ".agents/skills" install_hint = ( - "Install with `npm i -g @openai/codex` or " - "`brew install --cask codex`." + "Install with `npm i -g @openai/codex` or " "`brew install --cask codex`." ) otel_payload_support = "partial" # Codex is openai-protocol native. @@ -164,7 +169,9 @@ def write_static(self, working_dir: Path) -> list[str]: instructions = _load_master_instructions() if instructions: action = _append_or_write( - working_dir / "AGENTS.md", instructions, _DSAGT_MARKER, + working_dir / "AGENTS.md", + instructions, + _DSAGT_MARKER, ) if action: actions.append(action) @@ -198,6 +205,7 @@ def write_dynamic( """ del env, pdir import shutil + actions: list[str] = [] codex_home = Path(working_dir) / ".codex-data" codex_home.mkdir(parents=True, exist_ok=True) @@ -220,7 +228,7 @@ def write_dynamic( base_toml = user_config.read_text() if user_config.exists() else "" mcp_env = _mcp_env_block(config) body = _render_codex_config(mcp_env) - merged = (base_toml.rstrip() + "\n\n" + body if base_toml else body) + merged = base_toml.rstrip() + "\n\n" + body if base_toml else body config_path.write_text(merged + "\n") actions.append(f"Wrote {config_path} ({len(mcp_env)} MCP env vars)") return actions @@ -233,6 +241,7 @@ def launch_oneliner(self, project: str, project_dir: Path) -> str: """ del project import shlex + pdir = shlex.quote(str(project_dir)) codex_home = shlex.quote(str(project_dir / ".codex-data")) return f"cd {pdir} && CODEX_HOME={codex_home} codex" @@ -254,6 +263,7 @@ def proxy_write_dynamic( """ del env, pdir import shutil + actions: list[str] = [] codex_home = Path(working_dir) / ".codex-data" codex_home.mkdir(parents=True, exist_ok=True) @@ -282,11 +292,9 @@ def proxy_write_dynamic( base_toml = user_config.read_text() if user_config.exists() else "" mcp_env = _mcp_env_block(config) body = _render_codex_config_proxy(mcp_env, proxy_port, model) - merged = (base_toml.rstrip() + "\n\n" + body if base_toml else body) + merged = base_toml.rstrip() + "\n\n" + body if base_toml else body config_path.write_text(merged + "\n") - actions.append( - f"Wrote {config_path} ({len(mcp_env)} MCP env vars, proxy mode)" - ) + actions.append(f"Wrote {config_path} ({len(mcp_env)} MCP env vars, proxy mode)") return actions def runtime_env(self, config: dict) -> dict[str, str]: @@ -312,10 +320,12 @@ def run_script( if not text: return 1 cmd = [ - "codex", "exec", + "codex", + "exec", "--dangerously-bypass-approvals-and-sandbox", "--skip-git-repo-check", - "-C", str(working_dir), + "-C", + str(working_dir), text, ] return _run_simple_script(cmd, env, working_dir, self.install_hint) diff --git a/src/dsagt/agents/goose.py b/src/dsagt/agents/goose.py index 71f5422..bff8a4b 100644 --- a/src/dsagt/agents/goose.py +++ b/src/dsagt/agents/goose.py @@ -51,6 +51,7 @@ class GooseSetup(AgentSetup): name = "goose" base_command = ["goose", "session"] static_marker = ".goosehints" + native_skills_dir = ".agents/skills" # cross-agent standard goose discovers install_hint = "See https://github.com/block/goose for installation." otel_payload_support = "full" # Multi-protocol; goose's runtime reads provider-specific creds plus @@ -62,21 +63,32 @@ class GooseSetup(AgentSetup): # Without HOST set, goose ignores BASE_URL and hits the provider's # default endpoint — silently for users with a lab gateway. credential_env_vars = ( - "GOOSE_PROVIDER", "GOOSE_MODEL", - "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_HOST", + "GOOSE_PROVIDER", + "GOOSE_MODEL", + "ANTHROPIC_API_KEY", + "ANTHROPIC_BASE_URL", + "ANTHROPIC_HOST", "ANTHROPIC_MODEL", - "OPENAI_API_KEY", "OPENAI_BASE_URL", "OPENAI_HOST", + "OPENAI_API_KEY", + "OPENAI_BASE_URL", + "OPENAI_HOST", ) # Goose's Rust client emits OTel automatically when # OTEL_EXPORTER_OTLP_ENDPOINT is set — no per-platform flags needed. telemetry_env = {} credential_hints = ( - ("GOOSE_PROVIDER", "anthropic, openai, etc. (skip if global ~/.config/goose configured)"), + ( + "GOOSE_PROVIDER", + "anthropic, openai, etc. (skip if global ~/.config/goose configured)", + ), ("GOOSE_MODEL", "the model name your provider serves"), ("ANTHROPIC_API_KEY", "if GOOSE_PROVIDER=anthropic"), ("ANTHROPIC_HOST", "if GOOSE_PROVIDER=anthropic and on a gateway / proxy"), ("OPENAI_API_KEY", "if GOOSE_PROVIDER=openai"), - ("OPENAI_HOST", "if GOOSE_PROVIDER=openai and on a gateway / proxy (NOT OPENAI_BASE_URL — goose ignores that)"), + ( + "OPENAI_HOST", + "if GOOSE_PROVIDER=openai and on a gateway / proxy (NOT OPENAI_BASE_URL — goose ignores that)", + ), ) def write_static(self, working_dir: Path) -> list[str]: @@ -84,7 +96,9 @@ def write_static(self, working_dir: Path) -> list[str]: instructions = _load_master_instructions() if instructions: action = _append_or_write( - working_dir / ".goosehints", instructions, _DSAGT_MARKER, + working_dir / ".goosehints", + instructions, + _DSAGT_MARKER, ) if action: actions.append(action) @@ -103,16 +117,18 @@ def write_dynamic( del config, env, pdir actions: list[str] = [] - goose_config: dict = {"extensions": {}} - for server in ("registry", "knowledge"): - args = _mcp_server_args(server) - goose_config["extensions"][server] = { - "enabled": True, - "name": server, - "type": "stdio", - "cmd": "uv " + " ".join(args), - "timeout": 300, + args = _mcp_server_args() + goose_config: dict = { + "extensions": { + "dsagt": { + "enabled": True, + "name": "dsagt", + "type": "stdio", + "cmd": "uv " + " ".join(args), + "timeout": 300, + } } + } goose_path = working_dir / "goose.yaml" goose_path.write_text( @@ -160,8 +176,7 @@ def interactive_command(self, config: dict) -> list[str]: """ del config cmd = list(self.base_command) - for server in ("registry", "knowledge"): - cmd.extend(["--with-extension", f"uv run dsagt-{server}-server"]) + cmd.extend(["--with-extension", "uv run dsagt-server"]) return cmd def run_script( @@ -175,8 +190,13 @@ def run_script( """Single ``goose run`` call — goose's instructions file IS multi-turn.""" del config env["GOOSE_MODE"] = "auto" - cmd = ["goose", "run", "--instructions", str(script_path), - "--max-turns", str(max_turns)] - for server in ("registry", "knowledge"): - cmd.extend(["--with-extension", f"uv run dsagt-{server}-server"]) + cmd = [ + "goose", + "run", + "--instructions", + str(script_path), + "--max-turns", + str(max_turns), + ] + cmd.extend(["--with-extension", "uv run dsagt-server"]) return _run_simple_script(cmd, env, working_dir, self.install_hint) diff --git a/src/dsagt/agents/opencode.py b/src/dsagt/agents/opencode.py index 34b6bf2..de764e0 100644 --- a/src/dsagt/agents/opencode.py +++ b/src/dsagt/agents/opencode.py @@ -72,15 +72,14 @@ def _render_opencode_config( "$schema": "https://opencode.ai/config.json", "mcp": {}, } - for server in ("registry", "knowledge"): - entry: dict = { - "type": "local", - "command": ["uv"] + _mcp_server_args(server), - "enabled": True, - } - if mcp_env: - entry["environment"] = dict(mcp_env) - config["mcp"][f"dsagt-{server}"] = entry + entry: dict = { + "type": "local", + "command": ["uv"] + _mcp_server_args(), + "enabled": True, + } + if mcp_env: + entry["environment"] = dict(mcp_env) + config["mcp"]["dsagt"] = entry providers: dict = {} if present_creds.get("OPENAI_API_KEY"): @@ -113,7 +112,9 @@ def _render_opencode_config( def _render_opencode_config_proxy( - mcp_env: dict, proxy_port: int, opencode_model: str | None, + mcp_env: dict, + proxy_port: int, + opencode_model: str | None, ) -> str: """Phase-2 proxy-mode opencode.json body. @@ -128,31 +129,35 @@ def _render_opencode_config_proxy( don't trip ProviderModelNotFoundError. """ from .base import _PROXY_FORWARDED_SENTINEL + proxy_url = f"http://localhost:{proxy_port}" config: dict = { "$schema": "https://opencode.ai/config.json", "mcp": {}, } - for server in ("registry", "knowledge"): - entry: dict = { - "type": "local", - "command": ["uv"] + _mcp_server_args(server), - "enabled": True, - } - if mcp_env: - entry["environment"] = dict(mcp_env) - config["mcp"][f"dsagt-{server}"] = entry + entry: dict = { + "type": "local", + "command": ["uv"] + _mcp_server_args(), + "enabled": True, + } + if mcp_env: + entry["environment"] = dict(mcp_env) + config["mcp"]["dsagt"] = entry # Both providers point at the proxy — opencode picks via model prefix. providers: dict = { - "openai": {"options": { - "apiKey": _PROXY_FORWARDED_SENTINEL, - "baseURL": proxy_url, - }}, - "anthropic": {"options": { - "apiKey": _PROXY_FORWARDED_SENTINEL, - "baseURL": proxy_url, - }}, + "openai": { + "options": { + "apiKey": _PROXY_FORWARDED_SENTINEL, + "baseURL": proxy_url, + } + }, + "anthropic": { + "options": { + "apiKey": _PROXY_FORWARDED_SENTINEL, + "baseURL": proxy_url, + } + }, } if opencode_model and "/" in opencode_model: @@ -179,17 +184,25 @@ class OpenCodeSetup(AgentSetup): # multi-protocol story, no on-disk credential leakage. credential_env_vars = ( "OPENCODE_MODEL", - "OPENAI_API_KEY", "OPENAI_BASE_URL", - "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", + "OPENAI_API_KEY", + "OPENAI_BASE_URL", + "ANTHROPIC_API_KEY", + "ANTHROPIC_BASE_URL", ) telemetry_env = {} credential_hints = ( - ("OPENCODE_MODEL", "model spec '/' " - "(e.g. 'openai/claude-haiku-4-5-20251001-v1-project' for a PNNL-shape " - "openai gateway, or 'anthropic/claude-sonnet-4-5')"), + ( + "OPENCODE_MODEL", + "model spec '/' " + "(e.g. 'openai/claude-haiku-4-5-20251001-v1-project' for a PNNL-shape " + "openai gateway, or 'anthropic/claude-sonnet-4-5')", + ), ("OPENAI_API_KEY", "if your gateway speaks openai wire protocol"), - ("OPENAI_BASE_URL", "openai gateway URL " - "(referenced by opencode.json's provider.openai.options.baseURL)"), + ( + "OPENAI_BASE_URL", + "openai gateway URL " + "(referenced by opencode.json's provider.openai.options.baseURL)", + ), ("ANTHROPIC_API_KEY", "if your gateway speaks anthropic wire protocol"), ("ANTHROPIC_BASE_URL", "anthropic gateway URL"), ) @@ -199,7 +212,9 @@ def write_static(self, working_dir: Path) -> list[str]: instructions = _load_master_instructions() if instructions: action = _append_or_write( - working_dir / "AGENTS.md", instructions, _DSAGT_MARKER, + working_dir / "AGENTS.md", + instructions, + _DSAGT_MARKER, ) if action: actions.append(action) @@ -228,18 +243,21 @@ def write_dynamic( present = { name: bool(env.get(name)) for name in ( - "OPENAI_API_KEY", "OPENAI_BASE_URL", - "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", + "OPENAI_API_KEY", + "OPENAI_BASE_URL", + "ANTHROPIC_API_KEY", + "ANTHROPIC_BASE_URL", ) } body = _render_opencode_config( - mcp_env, present, opencode_model=env.get("OPENCODE_MODEL"), + mcp_env, + present, + opencode_model=env.get("OPENCODE_MODEL"), ) config_path = working_dir / "opencode.json" config_path.write_text(body + "\n") n_providers = sum( - 1 for k in ("OPENAI_API_KEY", "ANTHROPIC_API_KEY") - if present.get(k) + 1 for k in ("OPENAI_API_KEY", "ANTHROPIC_API_KEY") if present.get(k) ) actions.append( f"Wrote {config_path} ({len(mcp_env)} MCP env vars, " @@ -279,9 +297,7 @@ def proxy_write_dynamic( body = _render_opencode_config_proxy(mcp_env, proxy_port, opencode_model) config_path = working_dir / "opencode.json" config_path.write_text(body + "\n") - actions.append( - f"Wrote {config_path} ({len(mcp_env)} MCP env vars, proxy mode)" - ) + actions.append(f"Wrote {config_path} ({len(mcp_env)} MCP env vars, proxy mode)") return actions def run_script( @@ -314,10 +330,13 @@ def run_script( "agents/opencode.py credential_hints." ) cmd = [ - "opencode", "run", - "--dir", str(working_dir), + "opencode", + "run", + "--dir", + str(working_dir), "--dangerously-skip-permissions", - "-m", model, + "-m", + model, text, ] return _run_simple_script(cmd, env, working_dir, self.install_hint) diff --git a/src/dsagt/agents/roo.py b/src/dsagt/agents/roo.py index 6cbc403..bef9816 100644 --- a/src/dsagt/agents/roo.py +++ b/src/dsagt/agents/roo.py @@ -83,6 +83,7 @@ class RooSetup(AgentSetup): name = "roo" base_command = ["roo"] static_marker = ".roomodes" + native_skills_dir = ".roo/skills" install_hint = ( "Install via " "https://github.com/RooCodeInc/Roo-Code/blob/main/apps/cli/install.sh" @@ -94,17 +95,28 @@ class RooSetup(AgentSetup): # impossible with that path. Only the anthropic provider works for # gateways — the Anthropic SDK natively reads ``ANTHROPIC_BASE_URL``. credential_env_vars = ( - "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_MODEL", + "ANTHROPIC_API_KEY", + "ANTHROPIC_BASE_URL", + "ANTHROPIC_MODEL", ) # Roo emits no OTel — agent-side telemetry only via --proxy_traces. telemetry_env = {} credential_hints = ( - ("ANTHROPIC_API_KEY", "your provider API key (works for openai-shape " - "gateways too — the Anthropic SDK reaches them via ANTHROPIC_BASE_URL)"), - ("ANTHROPIC_BASE_URL", "gateway / proxy URL " - "(roo CLI has no --base-url flag; this env var is the only way)"), - ("ANTHROPIC_MODEL", "model name your gateway serves " - "(e.g. claude-haiku-4-5-20251001-v1-project)"), + ( + "ANTHROPIC_API_KEY", + "your provider API key (works for openai-shape " + "gateways too — the Anthropic SDK reaches them via ANTHROPIC_BASE_URL)", + ), + ( + "ANTHROPIC_BASE_URL", + "gateway / proxy URL " + "(roo CLI has no --base-url flag; this env var is the only way)", + ), + ( + "ANTHROPIC_MODEL", + "model name your gateway serves " + "(e.g. claude-haiku-4-5-20251001-v1-project)", + ), ) def vscode_hint(self, project_dir: Path) -> list[str]: @@ -176,6 +188,7 @@ def launch_oneliner(self, project: str, project_dir: Path) -> str: """ del project import shlex + pdir = shlex.quote(str(project_dir)) return f"cd {pdir} && roo --mode dsagt" @@ -225,20 +238,26 @@ def proxy_run_script( """ del max_turns from .base import _PROXY_FORWARDED_SENTINEL + model = (config.get("llm") or {}).get("model") if not model: - raise RuntimeError( - "roo proxy_run_script requires config['llm']['model']." - ) + raise RuntimeError("roo proxy_run_script requires config['llm']['model'].") cmd = [ "roo", - "--print", "--oneshot", - "--mode", "dsagt", - "--prompt-file", str(script_path), - "--workspace", str(working_dir), + "--print", + "--oneshot", + "--mode", + "dsagt", + "--prompt-file", + str(script_path), + "--workspace", + str(working_dir), "--debug", - "--provider", "anthropic", - "--api-key", _PROXY_FORWARDED_SENTINEL, - "--model", model, + "--provider", + "anthropic", + "--api-key", + _PROXY_FORWARDED_SENTINEL, + "--model", + model, ] return _run_simple_script(cmd, env, working_dir, self.install_hint) diff --git a/src/dsagt/commands/cli.py b/src/dsagt/commands/cli.py index 0e0b59c..0b5ab6c 100644 --- a/src/dsagt/commands/cli.py +++ b/src/dsagt/commands/cli.py @@ -391,12 +391,16 @@ def _cmd_skills(args): resolve_source, sync_source, ) - from dsagt.registry import ( - CATALOG_COLLECTION_PREFIX, - SKILLS_COLLECTION, - SkillRegistry, - ) + from dsagt.registry import SkillRegistry from dsagt.session import kb_from_config, load_config + from dsagt.skill_discovery import SkillRouter + + def _open_kb(): + """Best-effort KB; None when no embedder is configured (keyword fallback).""" + try: + return kb_from_config(config) + except Exception: + return None action = getattr(args, "skills_action", None) if not action: @@ -462,20 +466,22 @@ def _cmd_skills(args): if action == "list": if args.catalog: - kb = kb_from_config(config) + kb = _open_kb() try: - cats = [ - c for c in kb.collections if c.startswith(CATALOG_COLLECTION_PREFIX) - ] + reg = SkillRegistry(runtime_dir=pdir, kb=kb) + sources = SkillRouter(skill_registry=reg, kb=kb).list_sources() finally: - kb.close() - print( - "Catalog collections:" - if cats - else "No catalog synced. Run 'dsagt skills sync'." - ) - for c in sorted(cats): - print(f" {c}") + if kb is not None: + kb.close() + synced = [s for s in sources if s["synced"]] + if synced: + print("Synced catalog sources:") + for s in synced: + print( + f" {s['name']}: {s['indexed']} skill(s) indexed ({s['url']})" + ) + else: + print("No catalog synced. Run 'dsagt skills sync'.") else: reg = SkillRegistry(runtime_dir=pdir, kb=None) skills = reg.list_skills() @@ -485,28 +491,14 @@ def _cmd_skills(args): return 0 if action == "search": - kb = kb_from_config(config) + kb = _open_kb() try: - collections = [SKILLS_COLLECTION] + [ - c for c in kb.collections if c.startswith(CATALOG_COLLECTION_PREFIX) - ] - hits = [] - for coll in collections: - try: - hits.extend(kb.search(query=args.query, collection=coll, top_k=10)) - except (FileNotFoundError, KeyError, ValueError): - continue - hits.sort(key=lambda r: r.get("score", 0), reverse=True) - for r in hits[:10]: - meta = r.get("chunk", {}).get("metadata", {}) - print( - f" {meta.get('skill_name', '?')} ({r.get('score', 0):.2f}) " - f"[{meta.get('source', '')}]" - ) - if not hits: - print("No skills found.") + reg = SkillRegistry(runtime_dir=pdir, kb=kb) + router = SkillRouter(skill_registry=reg, kb=kb) + print(router.search(args.query)) finally: - kb.close() + if kb is not None: + kb.close() return 0 print(f"Unknown skills action: {action}", file=sys.stderr) diff --git a/src/dsagt/commands/dsagt_server.py b/src/dsagt/commands/dsagt_server.py new file mode 100644 index 0000000..b474d00 --- /dev/null +++ b/src/dsagt/commands/dsagt_server.py @@ -0,0 +1,247 @@ +"""DSAGT MCP Server — the single merged registry + knowledge server. + +Supersedes the two former servers (``dsagt-registry-server`` + +``dsagt-knowledge-server``). Both previously constructed their own +:class:`~dsagt.knowledge.KnowledgeBase` — two embedders, two Chroma accesses, +and a write-here/read-there hazard on the ``skills_catalog__*`` collections +(synced by knowledge, searched by registry). Merging into one process gives one +embedder, one Chroma owner, one ``init_tracing``, and one MCP server per agent. + +The heavy/risky work is already offloaded out of the event loop (``run_command`` +→ ``dsagt-run`` subprocess; ``kb_ingest`` → background job thread), so collapsing +the two processes costs little isolation. + +Tool *definitions* and *handlers* still live in their concern modules +(``registry_server`` / ``knowledge_server``); this module only composes their +``(tools, handlers)`` under one dispatch shell and owns the shared-KB startup. + +See ``design-notes/skills-catalog-server-merge.md`` §2. + +Backward compatibility is **rebuild-not-migrate**: a project created against the +old two-server layout adopts this by re-running ``dsagt start`` (which +regenerates the per-agent MCP config to a single ``dsagt`` server). See the +upgrade note in the README. +""" + +import asyncio +import json +import logging +import os +from pathlib import Path + +import yaml + +import mcp.types as types +from mcp.server.lowlevel import Server + +from dsagt.commands.knowledge_server import _knowledge_tools_and_handlers +from dsagt.commands.registry_server import ( + _registry_tools_and_handlers, + _run_stdio, +) +from dsagt.knowledge import KnowledgeBase +from dsagt.registry import SkillRegistry, ToolRegistry + +os.environ["PYTHONUNBUFFERED"] = "1" + +logger = logging.getLogger(__name__) + + +def create_dsagt_server( + registry: ToolRegistry, + kb: KnowledgeBase | None, + skill_registry: SkillRegistry | None, + runtime_dir: str | Path | None = None, +): + """Compose the registry + knowledge tools under one MCP ``Server``. + + Test-facing API: build the two registries + a (mock) KB, then drive the + returned server via ``call_tool_sync()``. ``main()`` constructs the real + deps from project config before calling this. + """ + server = Server("dsagt") + + r_tools, r_handlers = _registry_tools_and_handlers(registry, kb, skill_registry) + k_tools, k_handlers = _knowledge_tools_and_handlers(kb, runtime_dir) + + tools = r_tools + k_tools + handlers = {**r_handlers, **k_handlers} + if len(handlers) != len(r_handlers) + len(k_handlers): + overlap = set(r_handlers) & set(k_handlers) + raise RuntimeError( + f"dsagt-server tool-name collision across modules: {overlap}" + ) + + @server.list_tools() + async def list_tools() -> list[types.Tool]: + return tools + + @server.call_tool() + async def call_tool(name: str, arguments: dict) -> list[types.TextContent]: + handler = handlers[name] # KeyError = bug in list_tools schema + # Registry handlers return a plain string and never raise; knowledge + # handlers return a dict and raise ValueError on bad input. One wrapper + # serves both: catch + wrap errors (knowledge contract), then format by + # return type — str passes through, dict is JSON-encoded. + try: + result = await handler(arguments) + except ValueError as e: + result = {"status": "error", "error": str(e)} + except Exception as e: + logger.exception("Unexpected error in tool '%s'", name) + result = {"status": "error", "error": f"Unexpected error: {e}"} + text = ( + result + if isinstance(result, str) + else json.dumps(result, ensure_ascii=False) + ) + return [types.TextContent(type="text", text=text)] + + return server + + +def _build_kb_from_config(config: dict, project_dir: Path) -> KnowledgeBase: + """Construct the one shared KnowledgeBase from project config. + + The single home for embedding-backend selection + the cross-backend + leakage guard that the two former server mains duplicated near-verbatim. + """ + from dsagt.session import REGISTRY_DIR, setup_runtime_kb + + kb_config = config["knowledge"] + emb_config = config["embedding"] + + backend = (emb_config.get("backend") or "local").lower() + if backend not in ("local", "api"): + raise ValueError( + f"embedding.backend must be 'local' or 'api' (got {backend!r})" + ) + + # Cross-backend leakage guard: HuggingFace identifiers ("org/repo") and + # OpenAI-style aliases ("text-embedding-3-small") share the same + # EMBEDDING_MODEL env var in most setups. When backend=local but the + # resolved model is an OpenAI-style alias (no slash), drop the override so + # we fall back to the LocalEmbeddingClient default rather than 404 from HF. + raw_model = (emb_config.get("model") or "").strip() + embedder_kwargs: dict = {} + if raw_model and not raw_model.startswith("${"): + looks_hf = "/" in raw_model + if backend == "local" and not looks_hf: + logger.warning( + "Ignoring embedding.model=%r for backend=local (does not look " + "like a HuggingFace identifier). Falling back to the " + "LocalEmbeddingClient default.", + raw_model, + ) + else: + embedder_kwargs["model"] = raw_model + if backend == "api": + base_url = emb_config.get("base_url") or "" + api_key = emb_config.get("api_key") or "" + if not base_url: + raise ValueError( + "embedding.backend='api' requires embedding.base_url in " + "dsagt_config.yaml. Either set it to your OpenAI-compatible " + "endpoint, or change backend to 'local'." + ) + if not api_key or api_key.startswith("${"): + raise ValueError( + "embedding.backend='api' requires embedding.api_key in " + "dsagt_config.yaml. Either fill it in (or export the " + "${EMBEDDING_API_KEY} env var), or change backend to 'local'." + ) + embedder_kwargs.update({"base_url": base_url, "api_key": api_key}) + + runtime_kb_dir = setup_runtime_kb(REGISTRY_DIR / "kb_index", project_dir) + logger.info("Knowledge backend: %s", backend) + kb = KnowledgeBase( + index_dir=runtime_kb_dir, + chunk_size=kb_config["chunk_size"], + default_rerank=kb_config["rerank"], + default_embedder=backend, + default_index=kb_config["vector_db"], + embedder_kwargs=embedder_kwargs, + ) + # Background-load the embedder so the model is ready when the agent's first + # search / kb call lands (otherwise the first call pays the ~5-10s + # sentence-transformers import + construction, which looks like a hang). + kb.preload_default_embedder() + return kb + + +def main(): + """Entry point for ``dsagt-server``. + + All configuration comes from the project directory: + - ``./dsagt_config.yaml`` → project path + non-secret settings + - ``EMBEDDING_*`` env vars → embedding credentials + + No CLI arguments. By contract the agent's launch one-liner is + ``cd && ``, so cwd is project_dir for the MCP children it + spawns. + """ + from dsagt.observability import ( + configure_litellm_retries, + find_project_config, + init_tracing, + ) + from dsagt.session import resolve_env_vars + + project_dir, _cfg = find_project_config() + if project_dir is None: + raise RuntimeError( + "dsagt-server: no dsagt_config.yaml in cwd " + f"({Path.cwd()}). Launch the agent from the project " + "directory (`cd && `)." + ) + + log_file = project_dir / "dsagt_server.log" + # Default INFO; users opt into DEBUG via DSAGT_LOG_LEVEL=DEBUG. At DEBUG, + # transitive libraries (httpcore, urllib3, llama_index, chromadb) flood + # stderr with one line per network op — when an agent pipes the MCP + # server's stderr into its own debug stream, human output gets buried. + _level_name = os.environ.get("DSAGT_LOG_LEVEL", "INFO").upper() + _level = getattr(logging, _level_name, logging.INFO) + logging.basicConfig( + level=_level, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + handlers=[ + logging.FileHandler(log_file, mode="a"), + logging.StreamHandler(), + ], + ) + logger.info("Server starting — project_dir: %s, log: %s", project_dir, log_file) + + config_path = project_dir / "dsagt_config.yaml" + config = resolve_env_vars(yaml.safe_load(config_path.read_text())) + + init_tracing("dsagt-server") # session_id picked up from DSAGT_SESSION_ID env + configure_litellm_retries() + + kb = _build_kb_from_config(config, project_dir) + + # Bundled tools are pre-embedded in the shared ~/dsagt-projects/kb_index/ + # by ``dsagt setup-kb`` (or the auto-bootstrap in ``dsagt start``) and + # copied into the project's kb_index by ``setup_runtime_kb`` above. No + # bundled embedding work happens here; save_tool_spec incurs a single + # embed at save time. + registry = ToolRegistry( + source_tools_dir=None, + runtime_dir=str(project_dir), + kb=kb, + ) + skill_reg = SkillRegistry( + source_skills_dir=None, + runtime_dir=str(project_dir), + kb=kb, + ) + + server = create_dsagt_server(registry, kb, skill_reg, runtime_dir=str(project_dir)) + try: + asyncio.run(_run_stdio(server, "dsagt")) + finally: + kb.close() + + +if __name__ == "__main__": + main() diff --git a/src/dsagt/commands/info.py b/src/dsagt/commands/info.py index 63adf46..0669d0c 100644 --- a/src/dsagt/commands/info.py +++ b/src/dsagt/commands/info.py @@ -18,8 +18,7 @@ and the agent's own OTel SDK). Possible values: - ``claude-code`` / ``goose`` / ``cline`` / ``roo`` / ``codex`` — agent-emitted LLM-call traces (the bulk of traffic) - - ``dsagt-knowledge-server`` / ``dsagt-registry-server`` — MCP server - spans (``kb.*``, ``registry.*``) + - ``dsagt-server`` — merged MCP server spans (``kb.*``, ``registry.*``) - ``dsagt-run`` — tool-execute spans """ @@ -257,8 +256,8 @@ def _fmt_count(n: int) -> str: # trace, so the no-root-span fallback in _source_from_spans needs # this name registered too. ("proxy_pre_call", "dsagt-proxy"), - ("kb.", "dsagt-knowledge-server"), - ("registry.", "dsagt-registry-server"), + ("kb.", "dsagt-server"), + ("registry.", "dsagt-server"), ("tool.execute", "dsagt-run"), ) diff --git a/src/dsagt/commands/knowledge_server.py b/src/dsagt/commands/knowledge_server.py index 2bae002..bf7a8c9 100644 --- a/src/dsagt/commands/knowledge_server.py +++ b/src/dsagt/commands/knowledge_server.py @@ -13,8 +13,10 @@ project's dsagt_config.yaml. Embedding credentials flow through env vars (LLM_API_KEY, OPENAI_BASE_URL, EMBEDDING_MODEL) set by dsagt start. -Usage: - dsagt-knowledge-server --base-index-dir ./kb_index --runtime-dir ./runtime +These tool definitions + handlers run inside the merged ``dsagt-server`` +(see ``dsagt.commands.dsagt_server``). There is no standalone knowledge-server +entry point; ``create_knowledge_server`` is retained only as a test-facing +constructor. """ import asyncio @@ -34,7 +36,6 @@ import mcp.server.stdio import mcp.types as types -import yaml from mcp.server.lowlevel import Server, NotificationOptions from mcp.server.models import InitializationOptions @@ -46,11 +47,8 @@ ) from dsagt.memory import SuggestionQueue from dsagt.memory import ExplicitMemory -from dsagt.session import ( - REGISTRY_DIR, - _collection_exists, - setup_runtime_kb, -) # noqa: F401 +from dsagt.session import _collection_exists +from dsagt.session import setup_runtime_kb # noqa: F401 (re-exported for tests) logger = logging.getLogger(__name__) @@ -76,7 +74,8 @@ async def _run_stdio(server: Server, name: str) -> None: ) -# _collection_exists and setup_runtime_kb live in dsagt.session (imported above). +# _collection_exists is used below; setup_runtime_kb is re-exported for tests + +# the merged dsagt_server. Both live in dsagt.session (imported above). def _register_external_collection( @@ -580,8 +579,8 @@ async def _handle_add_skill_source( KNOWN_SOURCES, persist_source_to_config, resolve_source, - sync_source, ) + from dsagt.skill_discovery import SkillRouter source = arguments.get("source") if not source: @@ -592,7 +591,8 @@ async def _handle_add_skill_source( spec = resolve_source(source) if isinstance(source, str) and source in KNOWN_SOURCES: spec.setdefault("name", source) - stats = await asyncio.to_thread(sync_source, source, kb=kb) + router = SkillRouter(kb=kb) + stats = await asyncio.to_thread(router.sync, source) except (ValueError, RuntimeError) as e: return {"error": str(e)} persist_source_to_config( @@ -615,30 +615,23 @@ async def _handle_list_skill_sources(arguments: dict, *, kb: KnowledgeBase) -> d flag + ``indexed`` count inline means the agent doesn't have to cross- reference a separate ``synced_collections`` list to tell the difference. """ - import json - from dsagt.commands.skills_catalog import KNOWN_SOURCES, _repo_slug from dsagt.registry import CATALOG_COLLECTION_PREFIX, catalog_collection + from dsagt.skill_discovery import SkillRouter synced = {c for c in kb.collections if c.startswith(CATALOG_COLLECTION_PREFIX)} - def _indexed_count(collection: str) -> int: - ids = Path(kb.index_dir) / collection / "chroma_ids.json" - try: - return len(json.loads(ids.read_text())) - except (FileNotFoundError, ValueError): - return 0 - - sources = {} - for name, s in KNOWN_SOURCES.items(): - coll = catalog_collection(_repo_slug(s["url"])) - is_synced = coll in synced - sources[name] = { + # Single source of truth for the per-source synced/indexed view (shared + # with the CLI `skills list --catalog`). + sources = { + s["name"]: { "url": s["url"], - "description": s.get("description", ""), - "synced": is_synced, - "indexed": _indexed_count(coll) if is_synced else 0, + "description": s["description"], + "synced": s["synced"], + "indexed": s["indexed"], } + for s in SkillRouter(kb=kb).list_sources() + } # Surface any synced catalog whose source isn't in KNOWN_SOURCES (added # by raw GitHub URL) so the count is never silently dropped. @@ -661,21 +654,19 @@ def _indexed_count(collection: str) -> int: } -def create_knowledge_server( +def _knowledge_tools_and_handlers( kb: KnowledgeBase, runtime_dir: str | Path | None = None, ): - """Create and configure the MCP knowledge server. + """Build the knowledge server's ``(tool defs, handler map)``. - This is the test-facing API: tests call it with a mock KB and get back - a server they can drive via call_tool_sync(). main() reads the project - config and constructs KB before calling this. + Split out from :func:`create_knowledge_server` so the merged + ``dsagt-server`` can combine these with the registry server's tools + under one MCP ``Server`` without re-declaring them. The rerank default is on ``kb.default_rerank`` (set from ``knowledge.rerank`` in dsagt_config.yaml). """ - server = Server("knowledge") - mem_dir = Path(runtime_dir) if runtime_dir else kb.index_dir.parent memory = ExplicitMemory(runtime_dir=mem_dir) suggestions = SuggestionQueue(mem_dir / "suggestions.json") @@ -706,287 +697,304 @@ def create_knowledge_server( ), } - @server.list_tools() - async def list_tools() -> list[types.Tool]: - return [ - types.Tool( - name="add_skill_source", - description=( - "Enable an external agent-skill source (a known name like " - "'scientific'/'anthropic'/'antigravity'/'composio', or a GitHub URL). " - "Clones it and indexes its skills into the searchable catalog " - "(search_skills). Does NOT load them into context." - ), - inputSchema={ - "type": "object", - "properties": { - "source": { - "type": "string", - "description": "Known source name or GitHub repo URL / owner/repo", - }, + tools = [ + types.Tool( + name="add_skill_source", + description=( + "Enable an external agent-skill source (a known name like " + "'scientific'/'anthropic'/'antigravity'/'composio', or a GitHub URL). " + "Clones it and indexes its skills into the searchable catalog " + "(search_skills). Does NOT load them into context." + ), + inputSchema={ + "type": "object", + "properties": { + "source": { + "type": "string", + "description": "Known source name or GitHub repo URL / owner/repo", }, - "required": ["source"], }, + "required": ["source"], + }, + ), + types.Tool( + name="list_skill_sources", + description="List known + synced external skill sources and their indexed catalogs.", + inputSchema={"type": "object", "properties": {}}, + ), + types.Tool( + name="kb_list_collections", + description=( + "List all available knowledge base collections with their " + "embedding model and vector DB. Use this to discover what " + "documentation is already indexed." ), - types.Tool( - name="list_skill_sources", - description="List known + synced external skill sources and their indexed catalogs.", - inputSchema={"type": "object", "properties": {}}, - ), - types.Tool( - name="kb_list_collections", - description=( - "List all available knowledge base collections with their " - "embedding model and vector DB. Use this to discover what " - "documentation is already indexed." - ), - inputSchema={"type": "object", "properties": {}}, + inputSchema={"type": "object", "properties": {}}, + ), + types.Tool( + name="kb_search", + description=( + "Search knowledge base collections using semantic similarity. " + "Returns relevant chunks with source metadata. " + "Supports multi-collection search." ), - types.Tool( - name="kb_search", - description=( - "Search knowledge base collections using semantic similarity. " - "Returns relevant chunks with source metadata. " - "Supports multi-collection search." - ), - inputSchema={ - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "Natural language search query", - }, - "collection": { - "type": "string", - "description": "Name of a single collection to search", - }, - "collections": { - "type": "array", - "items": {"type": "string"}, - "description": "Search multiple collections and merge results (overrides 'collection')", - }, - "top_k": { - "type": "integer", - "description": "Number of results to return (default: 5)", - "default": 5, - }, - "rerank": { - "type": "boolean", - "description": "Use cross-encoder reranking (slower but more accurate). Default from config.", - "default": kb.default_rerank, - }, - "category": { - "type": "string", - "description": "Filter by category tag (ChromaDB collections only)", - }, - "session_id": { - "type": "string", - "description": "Filter by session ID (ChromaDB collections only)", - }, - "tool_name": { - "type": "string", - "description": "Filter by tool name (ChromaDB collections only)", - }, - "source_type": { - "type": "string", - "description": "Filter by source type (ChromaDB collections only)", - }, - "return_code": { - "type": "integer", - "description": "Filter by tool exit code (ChromaDB collections only)", - }, + inputSchema={ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Natural language search query", + }, + "collection": { + "type": "string", + "description": "Name of a single collection to search", + }, + "collections": { + "type": "array", + "items": {"type": "string"}, + "description": "Search multiple collections and merge results (overrides 'collection')", + }, + "top_k": { + "type": "integer", + "description": "Number of results to return (default: 5)", + "default": 5, + }, + "rerank": { + "type": "boolean", + "description": "Use cross-encoder reranking (slower but more accurate). Default from config.", + "default": kb.default_rerank, + }, + "category": { + "type": "string", + "description": "Filter by category tag (ChromaDB collections only)", + }, + "session_id": { + "type": "string", + "description": "Filter by session ID (ChromaDB collections only)", + }, + "tool_name": { + "type": "string", + "description": "Filter by tool name (ChromaDB collections only)", + }, + "source_type": { + "type": "string", + "description": "Filter by source type (ChromaDB collections only)", + }, + "return_code": { + "type": "integer", + "description": "Filter by tool exit code (ChromaDB collections only)", }, - "required": ["query"], }, + "required": ["query"], + }, + ), + types.Tool( + name="kb_ingest", + description=( + "Index a folder as a new knowledge base collection. " + "Returns immediately with a job_id. " + "IMPORTANT: poll kb_job_status every 10 seconds and wait for " + "status='complete'. DO NOT call ingest again for the same " + "folder while a job is running." ), - types.Tool( - name="kb_ingest", - description=( - "Index a folder as a new knowledge base collection. " - "Returns immediately with a job_id. " - "IMPORTANT: poll kb_job_status every 10 seconds and wait for " - "status='complete'. DO NOT call ingest again for the same " - "folder while a job is running." - ), - inputSchema={ - "type": "object", - "properties": { - "folder_path": { - "type": "string", - "description": "Path to folder containing documents to index", - }, - "collection_name": { - "type": "string", - "description": "Name for the collection (default: folder name)", - }, - "file_types": { - "type": "array", - "items": {"type": "string"}, - "description": "File extensions to include, e.g. ['pdf', 'md', 'py']. Defaults to common types.", - }, - "embedding_backend": { - "type": "string", - "enum": list(EMBEDDER_REGISTRY.keys()), - "description": "Embedding backend override for this collection.", - }, - "embedding_model": { - "type": "string", - "description": "Embedding model override for this collection.", - }, - "vector_db": { - "type": "string", - "enum": list(VECTORINDEX_REGISTRY.keys()), - "description": "Vector database override for this collection.", - }, + inputSchema={ + "type": "object", + "properties": { + "folder_path": { + "type": "string", + "description": "Path to folder containing documents to index", + }, + "collection_name": { + "type": "string", + "description": "Name for the collection (default: folder name)", + }, + "file_types": { + "type": "array", + "items": {"type": "string"}, + "description": "File extensions to include, e.g. ['pdf', 'md', 'py']. Defaults to common types.", + }, + "embedding_backend": { + "type": "string", + "enum": list(EMBEDDER_REGISTRY.keys()), + "description": "Embedding backend override for this collection.", + }, + "embedding_model": { + "type": "string", + "description": "Embedding model override for this collection.", + }, + "vector_db": { + "type": "string", + "enum": list(VECTORINDEX_REGISTRY.keys()), + "description": "Vector database override for this collection.", }, - "required": ["folder_path"], }, + "required": ["folder_path"], + }, + ), + types.Tool( + name="kb_append", + description=( + "Add documents to an existing collection. Uses the same embedding " + "model and vector DB the collection was created with. " + "Returns immediately with a job_id -- poll kb_job_status for progress." ), - types.Tool( - name="kb_append", - description=( - "Add documents to an existing collection. Uses the same embedding " - "model and vector DB the collection was created with. " - "Returns immediately with a job_id -- poll kb_job_status for progress." - ), - inputSchema={ - "type": "object", - "properties": { - "collection": { - "type": "string", - "description": "Name of the existing collection to append to", - }, - "paths": { - "type": "array", - "items": {"type": "string"}, - "description": "List of file or folder paths to add", - }, - "file_types": { - "type": "array", - "items": {"type": "string"}, - "description": "File extensions to include when expanding folders.", - }, + inputSchema={ + "type": "object", + "properties": { + "collection": { + "type": "string", + "description": "Name of the existing collection to append to", + }, + "paths": { + "type": "array", + "items": {"type": "string"}, + "description": "List of file or folder paths to add", + }, + "file_types": { + "type": "array", + "items": {"type": "string"}, + "description": "File extensions to include when expanding folders.", }, - "required": ["collection", "paths"], }, + "required": ["collection", "paths"], + }, + ), + types.Tool( + name="kb_add_vector_db", + description=( + "Register an already-built external vector store as a collection. " + "Queries will be embedded via the API using the specified model." ), - types.Tool( - name="kb_add_vector_db", - description=( - "Register an already-built external vector store as a collection. " - "Queries will be embedded via the API using the specified model." - ), - inputSchema={ - "type": "object", - "properties": { - "collection_name": { - "type": "string", - "description": "Unique name for this collection", - }, - "vector_db": { - "type": "string", - "enum": ["chroma", "lancedb", "qdrant"], - "description": "Vector store backend type", - }, - "connection_params": { - "type": "object", - "description": "Backend-specific connection parameters.", - }, - "embedding_model": { - "type": "string", - "description": "The API model used to build this index", - }, - "description": { - "type": "string", - "description": "Human-readable description for agent discovery", - }, + inputSchema={ + "type": "object", + "properties": { + "collection_name": { + "type": "string", + "description": "Unique name for this collection", + }, + "vector_db": { + "type": "string", + "enum": ["chroma", "lancedb", "qdrant"], + "description": "Vector store backend type", + }, + "connection_params": { + "type": "object", + "description": "Backend-specific connection parameters.", + }, + "embedding_model": { + "type": "string", + "description": "The API model used to build this index", + }, + "description": { + "type": "string", + "description": "Human-readable description for agent discovery", }, - "required": [ - "collection_name", - "vector_db", - "connection_params", - "embedding_model", - ], }, - ), - types.Tool( - name="kb_job_status", - description="Check the status of a background ingest or append job.", - inputSchema={ - "type": "object", - "properties": { - "job_id": { - "type": "string", - "description": "Job ID returned by kb_ingest or kb_append", - }, + "required": [ + "collection_name", + "vector_db", + "connection_params", + "embedding_model", + ], + }, + ), + types.Tool( + name="kb_job_status", + description="Check the status of a background ingest or append job.", + inputSchema={ + "type": "object", + "properties": { + "job_id": { + "type": "string", + "description": "Job ID returned by kb_ingest or kb_append", }, - "required": ["job_id"], }, + "required": ["job_id"], + }, + ), + types.Tool( + name="kb_remember", + description=( + "Store a user-confirmed fact as an explicit memory. " + "These persist across sessions. Use 'supersedes' to replace an outdated memory." ), - types.Tool( - name="kb_remember", - description=( - "Store a user-confirmed fact as an explicit memory. " - "These persist across sessions. Use 'supersedes' to replace an outdated memory." - ), - inputSchema={ - "type": "object", - "properties": { - "text": { - "type": "string", - "description": "The fact to remember", - }, - "category": { - "type": "string", - "description": "Classification tag", - }, - "session_id": { - "type": "string", - "description": "Current session identifier", - }, - "supersedes": { - "type": "string", - "description": "entry_id of an existing memory this replaces", - }, - "promoted_from": { - "type": "string", - "description": "suggestion_id if promoted from outlier suggestion", - }, + inputSchema={ + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "The fact to remember", + }, + "category": { + "type": "string", + "description": "Classification tag", + }, + "session_id": { + "type": "string", + "description": "Current session identifier", + }, + "supersedes": { + "type": "string", + "description": "entry_id of an existing memory this replaces", + }, + "promoted_from": { + "type": "string", + "description": "suggestion_id if promoted from outlier suggestion", }, - "required": ["text"], }, + "required": ["text"], + }, + ), + types.Tool( + name="kb_get_memories", + description=( + "Get all active explicit memories for this project. " + "Call at session start to load project context." ), - types.Tool( - name="kb_get_memories", - description=( - "Get all active explicit memories for this project. " - "Call at session start to load project context." - ), - inputSchema={"type": "object", "properties": {}}, - ), - types.Tool( - name="kb_get_suggestions", - description=( - "Get pending memory suggestions flagged by outlier detection. " - "Present to user for confirmation or dismissal." - ), - inputSchema={"type": "object", "properties": {}}, + inputSchema={"type": "object", "properties": {}}, + ), + types.Tool( + name="kb_get_suggestions", + description=( + "Get pending memory suggestions flagged by outlier detection. " + "Present to user for confirmation or dismissal." ), - types.Tool( - name="kb_dismiss_suggestion", - description="Dismiss a pending memory suggestion.", - inputSchema={ - "type": "object", - "properties": { - "suggestion_id": { - "type": "string", - "description": "ID of the suggestion to dismiss", - }, + inputSchema={"type": "object", "properties": {}}, + ), + types.Tool( + name="kb_dismiss_suggestion", + description="Dismiss a pending memory suggestion.", + inputSchema={ + "type": "object", + "properties": { + "suggestion_id": { + "type": "string", + "description": "ID of the suggestion to dismiss", }, - "required": ["suggestion_id"], }, - ), - ] + "required": ["suggestion_id"], + }, + ), + ] + return tools, handlers + + +def create_knowledge_server( + kb: KnowledgeBase, + runtime_dir: str | Path | None = None, +): + """Create and configure the standalone MCP knowledge server. + + Test-facing API: tests call it with a mock KB and get back a server they + can drive via call_tool_sync(). The merged ``dsagt-server`` uses + :func:`_knowledge_tools_and_handlers` directly instead of this wrapper. + """ + server = Server("knowledge") + tools, handlers = _knowledge_tools_and_handlers(kb, runtime_dir) + + @server.list_tools() + async def list_tools() -> list[types.Tool]: + return tools @server.call_tool() async def call_tool(name: str, arguments: dict) -> list[types.TextContent]: @@ -1008,141 +1016,10 @@ async def call_tool(name: str, arguments: dict) -> list[types.TextContent]: # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- - - -def main(): - """Entry point for dsagt-knowledge-server. - - All configuration comes from the project directory: - - ``./dsagt_config.yaml`` → project path + non-secret settings - (chunk_size, vector_db, rerank) - - ``LLM_API_KEY``, ``OPENAI_BASE_URL`` env vars → embedding credentials - - No CLI arguments — the server derives everything from the YAML. By - contract the agent's launch one-liner is ``cd && ``, - so cwd is project_dir for the MCP children it spawns. - """ - from dsagt.observability import find_project_config - - project_dir, _ = find_project_config() - if project_dir is None: - raise RuntimeError( - "dsagt-knowledge-server: no dsagt_config.yaml in cwd " - f"({Path.cwd()}). Launch the agent from the project " - "directory (`cd && `)." - ) - - log_file = project_dir / "dsagt_knowledge_server.log" - # Default INFO; users opt into DEBUG via DSAGT_LOG_LEVEL=DEBUG. See - # registry_server.py main() for rationale (httpcore/urllib3/llama_index - # at DEBUG floods agent debug output). - _level_name = os.environ.get("DSAGT_LOG_LEVEL", "INFO").upper() - _level = getattr(logging, _level_name, logging.INFO) - logging.basicConfig( - level=_level, - format="%(asctime)s %(levelname)s %(name)s: %(message)s", - handlers=[ - logging.FileHandler(log_file, mode="a"), - logging.StreamHandler(), - ], - ) - logger.info("Server starting — project_dir: %s, log: %s", project_dir, log_file) - - # Read project config. Required — this server runs inside a project - # created by dsagt init. Every section must be present with all fields - # filled. dsagt init generates complete defaults; if anything is missing, - # the config is broken and the server fails fast. - config_path = project_dir / "dsagt_config.yaml" - from dsagt.session import resolve_env_vars - - config = resolve_env_vars(yaml.safe_load(config_path.read_text())) - - kb_config = config["knowledge"] - emb_config = config["embedding"] - - # Embedding backend selection. Default is "local" (sentence-transformers, - # CPU, no creds) so a fresh ``dsagt init`` works zero-config. Switching - # to "api" requires base_url + api_key — validate eagerly so a misconfig - # surfaces at MCP-server startup rather than at the first kb_search. - backend = (emb_config.get("backend") or "local").lower() - if backend not in ("local", "api"): - raise ValueError( - f"embedding.backend must be 'local' or 'api' (got {backend!r})" - ) - - # Only pass an explicit ``model`` when the user filled one in. Empty / - # ``${EMBEDDING_MODEL}`` placeholders mean "use the backend's default" - # — LocalEmbeddingClient has its own default; APIEmbeddingClient has - # no default and will raise downstream, which is what we want for a - # misconfigured api setup. - # - # Cross-backend leakage guard: HuggingFace identifiers ("org/repo") - # and OpenAI-style aliases ("text-embedding-3-small") share the same - # ``EMBEDDING_MODEL`` env var in most setups. When a user switches - # ``embedding.backend`` from api → local without also retargeting - # the env var, the api alias flows into LocalEmbeddingClient and - # produces a confusing 404 from HuggingFace at first embed. Drop - # the override when it's clearly mis-shaped for the active backend. - raw_model = (emb_config.get("model") or "").strip() - embedder_kwargs: dict = {} - if raw_model and not raw_model.startswith("${"): - looks_hf = "/" in raw_model - if backend == "local" and not looks_hf: - logger.warning( - "Ignoring embedding.model=%r for backend=local (does not " - "look like a HuggingFace identifier). Falling back to the " - "LocalEmbeddingClient default.", - raw_model, - ) - else: - embedder_kwargs["model"] = raw_model - if backend == "api": - base_url = emb_config.get("base_url") or "" - api_key = emb_config.get("api_key") or "" - if not base_url: - raise ValueError( - "embedding.backend='api' requires embedding.base_url in " - "dsagt_config.yaml. Either set it to your OpenAI-compatible " - "endpoint, or change backend to 'local'." - ) - if not api_key or api_key.startswith("${"): - raise ValueError( - "embedding.backend='api' requires embedding.api_key in " - "dsagt_config.yaml. Either fill it in (or export the " - "${EMBEDDING_API_KEY} env var), or change backend to 'local'." - ) - embedder_kwargs.update({"base_url": base_url, "api_key": api_key}) - - from dsagt.observability import init_tracing, configure_litellm_retries - - init_tracing( - "dsagt-knowledge-server" - ) # session_id picked up from DSAGT_SESSION_ID env - configure_litellm_retries() - - runtime_kb_dir = setup_runtime_kb(REGISTRY_DIR / "kb_index", project_dir) - - logger.info("Knowledge backend: %s", backend) - kb = KnowledgeBase( - index_dir=runtime_kb_dir, - chunk_size=kb_config["chunk_size"], - default_rerank=kb_config["rerank"], - default_embedder=backend, - default_index=kb_config["vector_db"], - embedder_kwargs=embedder_kwargs, - ) - # Background-load the embedder so the model is ready when the - # agent's first kb call lands. Without this, the first call pays - # the ~5–10s sentence-transformers import + SentenceTransformer - # construction cost, which looks like a hang to the operator. - kb.preload_default_embedder() - - server = create_knowledge_server(kb, runtime_dir=str(project_dir)) - try: - asyncio.run(_run_stdio(server, "knowledge")) - finally: - kb.close() - - -if __name__ == "__main__": - main() +# +# There is no standalone ``dsagt-knowledge-server`` entry point any more. The +# knowledge tools run inside the merged ``dsagt-server`` (see +# ``dsagt.commands.dsagt_server``), which composes +# :func:`_knowledge_tools_and_handlers` with the registry server's tools under +# one MCP ``Server`` + one shared ``KnowledgeBase``. ``create_knowledge_server`` +# above is retained as a standalone, test-facing constructor. diff --git a/src/dsagt/commands/registry_server.py b/src/dsagt/commands/registry_server.py index 5a57046..24d29b6 100644 --- a/src/dsagt/commands/registry_server.py +++ b/src/dsagt/commands/registry_server.py @@ -10,11 +10,12 @@ Server configuration (embedding credentials) flows through env vars (LLM_API_KEY, OPENAI_BASE_URL, EMBEDDING_MODEL) set by dsagt start. -Usage: - dsagt-registry-server --runtime-dir ./my_session +These tool definitions + handlers run inside the merged ``dsagt-server`` +(see ``dsagt.commands.dsagt_server``). There is no standalone registry-server +entry point; ``create_registry_server`` is retained only as a test-facing +constructor. """ -import asyncio import json import logging import os @@ -40,12 +41,9 @@ ) from dsagt.provenance import reconstruct_pipeline from dsagt.registry import ( - CATALOG_COLLECTION_PREFIX, - SKILLS_COLLECTION, TOOLS_COLLECTION, SkillRegistry, ToolRegistry, - _parse_frontmatter, ) os.environ["PYTHONUNBUFFERED"] = "1" @@ -206,9 +204,10 @@ async def _handle_save_skill( ) -> str: """Register a skill (workflow / agent instructions) for later reuse. - Symmetric with save_tool_spec — writes SKILL.md to - ``/skills//`` and indexes it into - ``registered_skills`` so future ``search_skills`` calls find it. + Writes SKILL.md to ``/skills//``, where every supported + agent natively auto-discovers it (after the next ``dsagt start`` mirror). + No KB indexing — ``search_skills`` covers only the not-yet-installed + *catalog* tier, since installed skills are already natively discoverable. """ spec = arguments["spec"] if isinstance(spec, str): @@ -308,89 +307,23 @@ async def _handle_search_skills( kb: KnowledgeBase | None, skill_registry: SkillRegistry | None, ) -> str: - skill_name = arguments.get("skill_name") - query = arguments.get("query", "") - tag = arguments.get("tag") - top_k = arguments.get("top_k", 10) - - if skill_name and skill_registry: - skill = skill_registry.get_skill(skill_name) - if skill: - return f"Found skill '{skill_name}':\n\n" + yaml.dump( - skill, default_flow_style=False, sort_keys=False - ) - return f"No skill named '{skill_name}'." + if skill_registry is None: + return "search_skills is unavailable (no skill registry configured)." - if kb is None: - return ( - "search_skills requires a configured knowledge base " - "(set embedding.api_key + embedding.base_url + embedding.model " - "in dsagt_config.yaml). Use search_skills with an exact " - "skill_name for KB-free lookups." - ) - - # Search the installed/bundled ``skills`` collection AND every external - # ``skills_catalog__`` collection, then merge by score. Installed - # skills are also natively discovered by the agent; the catalog is the - # part native discovery can't do (it isn't loaded into context). - catalog_collections = [ - c for c in kb.collections if c.startswith(CATALOG_COLLECTION_PREFIX) - ] - collections = [SKILLS_COLLECTION] + catalog_collections - fetch_k = top_k * 3 if tag else top_k - results: list[dict] = [] - for coll in collections: - try: - results.extend( - kb.search(query=query or "skill", collection=coll, top_k=fetch_k) - ) - except (FileNotFoundError, KeyError, ValueError): - continue # collection absent/empty on this KB — skip - if tag: - results = [ - r - for r in results - if tag in r.get("chunk", {}).get("metadata", {}).get("tags", "") - ] - results.sort(key=lambda r: r.get("score", 0), reverse=True) - results = results[:top_k] - if not results: - if not catalog_collections: - return ( - "No skills found matching the query. Note: no external skill " - "catalog is synced yet, so only installed skills were searched. " - "Call list_skill_sources() to see available sources, then " - "add_skill_source(source=...) (e.g. 'scientific' for " - "materials/chem/bio) to sync one before searching again." - ) - return "No skills found matching the query." + from dsagt.skill_discovery import SkillRouter - summaries = [] - for r in results: - chunk = r.get("chunk", {}) - meta = chunk.get("metadata", {}) - src = meta.get("source", "") - where = ( - " [installed]" - if src in ("bundled", "registered") - else ( - " [catalog · install_skill to add]" - if src.startswith("catalog:") - else "" - ) - ) - summaries.append( - f"- **{meta.get('skill_name', 'unknown')}**{where} " - f"(score: {r.get('score', 0):.2f})\n" - f" {chunk.get('text', '')[:200]}" - ) - return f"Found {len(results)} skill(s):\n\n" + "\n\n".join(summaries) + router = SkillRouter(skill_registry=skill_registry, kb=kb) + return router.search( + arguments.get("query", ""), + top_k=arguments.get("top_k", 10), + tag=arguments.get("tag"), + skill_name=arguments.get("skill_name"), + ) async def _handle_install_skill( arguments: dict, *, - skill_registry: SkillRegistry | None, runtime_dir: Path, ) -> str: """Install a catalog skill into ``/skills//``. @@ -400,23 +333,26 @@ async def _handle_install_skill( the next ``dsagt start`` (which mirrors installed skills into ``.claude/skills/`` before launch) plus an agent restart. """ - from dsagt.commands.skills_catalog import install_into_project + from dsagt.skill_discovery import SkillRouter name = arguments.get("skill_name") if not name: return "install_skill requires 'skill_name'." try: - info = install_into_project(name, runtime_dir) + info = SkillRouter().install(name, runtime_dir) except LookupError as e: return f"Error: {e}" - # Index the now-installed skill as a project ('registered') skill too, so - # non-native agents can still find it via search_skills after install. - if skill_registry is not None and skill_registry._kb is not None: - skill_md = Path(info["dest_dir"]) / "SKILL.md" - spec = _parse_frontmatter(skill_md) - if spec.get("name"): - skill_registry._index_skill(spec, skill_md) + # No KB re-index: the installed skill now lives in /skills/ where + # the agent natively auto-discovers it (after the next `dsagt start` mirror). + # search_skills covers only the not-yet-installed catalog tier. + attribution = info.get("attribution") or [] + attribution_note = ( + f"\nPreserved upstream license/attribution ({', '.join(attribution)}) " + f"+ PROVENANCE.txt." + if attribution + else "\nWrote PROVENANCE.txt recording the source." + ) return ( f"{info['action'].capitalize()} skill '{info['name']}' at " @@ -427,6 +363,7 @@ async def _handle_install_skill( f"Restart is only for hands-free auto-invocation: the next `dsagt start` " f"mirrors it into the platform's native skill dir (.claude/skills/), and " f"after relaunch the agent discovers and auto-invokes it without this tool." + f"{attribution_note}" ) @@ -491,18 +428,17 @@ async def _handle_install_dependencies( # --------------------------------------------------------------------------- -def create_registry_server( +def _registry_tools_and_handlers( registry: ToolRegistry, kb: KnowledgeBase | None = None, skill_registry: SkillRegistry | None = None, ): - """Create and configure the MCP registry server. + """Build the registry server's ``(tool defs, handler map)``. - Test-facing API: tests call with a mock registry and get back a server - they can drive via call_tool_sync(). main() constructs the registry - and KB from config before calling this. + Split out from :func:`create_registry_server` so the merged + ``dsagt-server`` can combine these with the knowledge server's tools + under one MCP ``Server`` without re-declaring them. """ - server = Server("registry") runtime_dir = Path(registry.runtime_dir) # Dispatch table — maps MCP tool names to handler functions with @@ -520,7 +456,6 @@ def create_registry_server( ), "install_skill": partial( _handle_install_skill, - skill_registry=skill_registry, runtime_dir=runtime_dir, ), "reconstruct_pipeline": partial( @@ -531,302 +466,320 @@ def create_registry_server( ), } - @server.list_tools() - async def list_tools() -> list[types.Tool]: - return [ - types.Tool( - name="read_file", - description="Read contents of a text file", - inputSchema={ - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Path to the file to read", - }, + tools = [ + types.Tool( + name="read_file", + description="Read contents of a text file", + inputSchema={ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path to the file to read", }, - "required": ["path"], }, - ), - types.Tool( - name="http_request", - description="Make an HTTP request to fetch documentation or API specs", - inputSchema={ - "type": "object", - "properties": { - "url": {"type": "string", "description": "URL to request"}, - "method": { - "type": "string", - "description": "HTTP method", - "default": "GET", - }, - "headers": { - "type": "object", - "description": "Optional headers", - }, + "required": ["path"], + }, + ), + types.Tool( + name="http_request", + description="Make an HTTP request to fetch documentation or API specs", + inputSchema={ + "type": "object", + "properties": { + "url": {"type": "string", "description": "URL to request"}, + "method": { + "type": "string", + "description": "HTTP method", + "default": "GET", + }, + "headers": { + "type": "object", + "description": "Optional headers", }, - "required": ["url"], }, - ), - types.Tool( - name="run_command", - description="Execute a command to get help/usage information", - inputSchema={ - "type": "object", - "properties": { - "command": { - "type": "string", - "description": "Command to execute", - }, - "args": { - "type": "array", - "items": {"type": "string"}, - "description": "Arguments (e.g., ['--help'])", - "default": [], - }, - "timeout": {"type": "number", "default": 10}, + "required": ["url"], + }, + ), + types.Tool( + name="run_command", + description="Execute a command to get help/usage information", + inputSchema={ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "Command to execute", + }, + "args": { + "type": "array", + "items": {"type": "string"}, + "description": "Arguments (e.g., ['--help'])", + "default": [], }, - "required": ["command"], + "timeout": {"type": "number", "default": 10}, }, - ), - types.Tool( - name="save_tool_spec", - description="Save a tool specification to the registry as a skill file", - inputSchema={ - "type": "object", - "properties": { - # ``anyOf`` accepts both a structured object and a - # JSON-encoded string. Some MCP clients (notably - # Claude Sonnet/Haiku 4.x) serialize nested object - # arguments as JSON strings instead of objects; the - # handler unwraps either shape. - "spec": { - "description": "Tool specification (object or JSON-encoded string)", - "anyOf": [ - { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Unique tool identifier", - }, - "description": { - "type": "string", - "description": "What the tool does", - }, - "executable": { - "type": "string", - "description": "Command to execute", - }, - "parameters": { + "required": ["command"], + }, + ), + types.Tool( + name="save_tool_spec", + description="Save a tool specification to the registry as a skill file", + inputSchema={ + "type": "object", + "properties": { + # ``anyOf`` accepts both a structured object and a + # JSON-encoded string. Some MCP clients (notably + # Claude Sonnet/Haiku 4.x) serialize nested object + # arguments as JSON strings instead of objects; the + # handler unwraps either shape. + "spec": { + "description": "Tool specification (object or JSON-encoded string)", + "anyOf": [ + { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Unique tool identifier", + }, + "description": { + "type": "string", + "description": "What the tool does", + }, + "executable": { + "type": "string", + "description": "Command to execute", + }, + "parameters": { + "type": "object", + "description": "Parameter definitions", + "additionalProperties": { "type": "object", - "description": "Parameter definitions", - "additionalProperties": { - "type": "object", - "properties": { - "type": { - "type": "string", - "description": "Parameter type", - }, - "required": {"type": "boolean"}, - "description": {"type": "string"}, - "default": { - "description": "Default value" - }, - "cli": { - "type": "string", - "description": ( - "How to render this parameter on the command line: " - "'positional[:N]' for positional args, '--name' or '-n' " - "for spaced flags, '--name=' or '-n=' for glued flags, " - "'key=' for dd-style key=value. Defaults to '--' " - "if omitted." - ), - }, + "properties": { + "type": { + "type": "string", + "description": "Parameter type", + }, + "required": {"type": "boolean"}, + "description": {"type": "string"}, + "default": { + "description": "Default value" + }, + "cli": { + "type": "string", + "description": ( + "How to render this parameter on the command line: " + "'positional[:N]' for positional args, '--name' or '-n' " + "for spaced flags, '--name=' or '-n=' for glued flags, " + "'key=' for dd-style key=value. Defaults to '--' " + "if omitted." + ), }, - "required": ["type", "description"], }, - }, - "dependencies": { - "type": "array", - "items": {"type": "string"}, - "description": "Python packages to install", - }, - "tags": { - "type": "array", - "items": {"type": "string"}, - "description": "Tags for categorizing the tool", + "required": ["type", "description"], }, }, - "required": [ - "name", - "description", - "executable", - "parameters", - ], + "dependencies": { + "type": "array", + "items": {"type": "string"}, + "description": "Python packages to install", + }, + "tags": { + "type": "array", + "items": {"type": "string"}, + "description": "Tags for categorizing the tool", + }, }, - {"type": "string"}, - ], - }, + "required": [ + "name", + "description", + "executable", + "parameters", + ], + }, + {"type": "string"}, + ], }, - "required": ["spec"], }, + "required": ["spec"], + }, + ), + types.Tool( + name="save_skill", + description=( + "Register a skill (agent workflow / instructions) into " + "/skills//SKILL.md, where the agent natively " + "auto-discovers it after the next `dsagt start`. Symmetric " + "with save_tool_spec — use this when you've designed a " + "reusable instruction set you want future sessions to load " + "automatically." ), - types.Tool( - name="save_skill", - description=( - "Register a skill (agent workflow / instructions) into " - "/skills//SKILL.md and index it into the " - "registered_skills KB collection. Symmetric with " - "save_tool_spec — use this when you've designed a " - "reusable instruction set you want future sessions to " - "discover via search_skills." - ), - inputSchema={ - "type": "object", - "properties": { - # ``anyOf`` for spec mirrors save_tool_spec — accept - # both structured object and JSON-encoded string for - # MCP clients that serialize nested args. - "spec": { - "description": "Skill spec (object or JSON-encoded string)", - "anyOf": [ - { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Unique skill identifier (becomes the directory name)", - }, - "description": { - "type": "string", - "description": "What the skill does / when to use it", - }, - "tags": { - "type": "array", - "items": {"type": "string"}, - "description": "Tags for categorizing the skill", - }, + inputSchema={ + "type": "object", + "properties": { + # ``anyOf`` for spec mirrors save_tool_spec — accept + # both structured object and JSON-encoded string for + # MCP clients that serialize nested args. + "spec": { + "description": "Skill spec (object or JSON-encoded string)", + "anyOf": [ + { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Unique skill identifier (becomes the directory name)", + }, + "description": { + "type": "string", + "description": "What the skill does / when to use it", + }, + "tags": { + "type": "array", + "items": {"type": "string"}, + "description": "Tags for categorizing the skill", }, - "required": ["name", "description"], - }, - {"type": "string"}, - ], - }, - "body": { - "type": "string", - "description": ( - "Markdown body of the SKILL.md (workflow / " - "instructions the agent will follow). When " - "updating an existing skill, omit to preserve " - "the existing body." - ), - }, - "reference_files": { - "description": ( - "Optional additional files to write into the " - "skill directory. Object mapping relative " - "path -> file contents, or JSON-encoded string." - ), - "anyOf": [ - { - "type": "object", - "additionalProperties": {"type": "string"}, }, - {"type": "string"}, - ], - }, + "required": ["name", "description"], + }, + {"type": "string"}, + ], + }, + "body": { + "type": "string", + "description": ( + "Markdown body of the SKILL.md (workflow / " + "instructions the agent will follow). When " + "updating an existing skill, omit to preserve " + "the existing body." + ), + }, + "reference_files": { + "description": ( + "Optional additional files to write into the " + "skill directory. Object mapping relative " + "path -> file contents, or JSON-encoded string." + ), + "anyOf": [ + { + "type": "object", + "additionalProperties": {"type": "string"}, + }, + {"type": "string"}, + ], }, - "required": ["spec"], }, - ), - types.Tool( - name="get_registry", - description="Get all tools from the registry", - inputSchema={"type": "object", "properties": {}}, - ), - types.Tool( - name="search_registry", - description="Search for tools by name, tag, or description via semantic search.", - inputSchema={ - "type": "object", - "properties": { - "query": {"type": "string", "description": "Search query"}, - "tag": {"type": "string", "description": "Filter by tag"}, - "tool_name": { - "type": "string", - "description": "Exact tool name lookup", - }, - "top_k": {"type": "integer", "default": 10}, + "required": ["spec"], + }, + ), + types.Tool( + name="get_registry", + description="Get all tools from the registry", + inputSchema={"type": "object", "properties": {}}, + ), + types.Tool( + name="search_registry", + description="Search for tools by name, tag, or description via semantic search.", + inputSchema={ + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search query"}, + "tag": {"type": "string", "description": "Filter by tag"}, + "tool_name": { + "type": "string", + "description": "Exact tool name lookup", }, + "top_k": {"type": "integer", "default": 10}, }, + }, + ), + types.Tool( + name="search_skills", + description=( + "Search agent skills by name, tag, or description. Spans installed " + "skills and the external installable catalog. Catalog hits are marked " + "'[catalog]' — use install_skill to add one to this project." ), - types.Tool( - name="search_skills", - description=( - "Search agent skills by name, tag, or description. Spans installed " - "skills and the external installable catalog. Catalog hits are marked " - "'[catalog]' — use install_skill to add one to this project." - ), - inputSchema={ - "type": "object", - "properties": { - "query": {"type": "string", "description": "Search query"}, - "tag": {"type": "string", "description": "Filter by tag"}, - "skill_name": { - "type": "string", - "description": "Exact skill name lookup", - }, - "top_k": {"type": "integer", "default": 10}, + inputSchema={ + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search query"}, + "tag": {"type": "string", "description": "Filter by tag"}, + "skill_name": { + "type": "string", + "description": "Exact skill name lookup", }, + "top_k": {"type": "integer", "default": 10}, }, + }, + ), + types.Tool( + name="install_skill", + description=( + "Install a skill from the external catalog (found via search_skills) " + "into this project so the agent can use it natively. Copies SKILL.md " + "+ scripts/references; available natively after the next restart." ), - types.Tool( - name="install_skill", - description=( - "Install a skill from the external catalog (found via search_skills) " - "into this project so the agent can use it natively. Copies SKILL.md " - "+ scripts/references; available natively after the next restart." - ), - inputSchema={ - "type": "object", - "properties": { - "skill_name": { - "type": "string", - "description": "Catalog skill name to install", - }, + inputSchema={ + "type": "object", + "properties": { + "skill_name": { + "type": "string", + "description": "Catalog skill name to install", }, - "required": ["skill_name"], }, - ), - types.Tool( - name="reconstruct_pipeline", - description="Reconstruct a reproducible pipeline script from tool execution records.", - inputSchema={ - "type": "object", - "properties": { - "format": { - "type": "string", - "enum": ["bash", "snakemake"], - "default": "bash", - }, + "required": ["skill_name"], + }, + ), + types.Tool( + name="reconstruct_pipeline", + description="Reconstruct a reproducible pipeline script from tool execution records.", + inputSchema={ + "type": "object", + "properties": { + "format": { + "type": "string", + "enum": ["bash", "snakemake"], + "default": "bash", }, }, - ), - types.Tool( - name="install_dependencies", - description="Install Python dependencies for one or all tools in the registry.", - inputSchema={ - "type": "object", - "properties": { - "tool_name": { - "type": "string", - "description": "Install deps for a specific tool (omit for all)", - }, + }, + ), + types.Tool( + name="install_dependencies", + description="Install Python dependencies for one or all tools in the registry.", + inputSchema={ + "type": "object", + "properties": { + "tool_name": { + "type": "string", + "description": "Install deps for a specific tool (omit for all)", }, }, - ), - ] + }, + ), + ] + return tools, handlers + + +def create_registry_server( + registry: ToolRegistry, + kb: KnowledgeBase | None = None, + skill_registry: SkillRegistry | None = None, +): + """Create and configure the standalone MCP registry server. + + Test-facing API: tests call with a mock registry and get back a server + they can drive via call_tool_sync(). The merged ``dsagt-server`` uses + :func:`_registry_tools_and_handlers` directly instead of this wrapper. + """ + server = Server("registry") + tools, handlers = _registry_tools_and_handlers(registry, kb, skill_registry) + + @server.list_tools() + async def list_tools() -> list[types.Tool]: + return tools @server.call_tool() async def call_tool(name: str, arguments: dict) -> list[types.TextContent]: @@ -840,135 +793,10 @@ async def call_tool(name: str, arguments: dict) -> list[types.TextContent]: # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- - - -def main(): - """Entry point for dsagt-registry-server. - - All configuration comes from the project directory: - - ``./dsagt_config.yaml`` → project path + name - - ``LLM_API_KEY``, ``OPENAI_BASE_URL`` env vars → embedding credentials - - No CLI arguments. By contract the agent's launch one-liner is - ``cd && ``, so cwd is project_dir for the MCP - children it spawns. - """ - import logging as _logging - from dsagt.observability import find_project_config - - project_dir, _cfg = find_project_config() - if project_dir is None: - raise RuntimeError( - "dsagt-registry-server: no dsagt_config.yaml in cwd " - f"({Path.cwd()}). Launch the agent from the project " - "directory (`cd && `)." - ) - - log_file = project_dir / "dsagt_registry_server.log" - # Default INFO; users opt into DEBUG via DSAGT_LOG_LEVEL=DEBUG. At DEBUG, - # transitive libraries (httpcore, urllib3, llama_index, chromadb) flood - # stderr with one line per network operation — when an agent like roo - # pipes the MCP server's stderr into its own debug stream, the human - # output gets buried under thousands of low-value lines. - _level_name = os.environ.get("DSAGT_LOG_LEVEL", "INFO").upper() - _level = getattr(_logging, _level_name, _logging.INFO) - _logging.basicConfig( - level=_level, - format="%(asctime)s %(levelname)s %(name)s: %(message)s", - handlers=[ - _logging.FileHandler(log_file, mode="a"), - _logging.StreamHandler(), - ], - ) - log = _logging.getLogger(__name__) - log.info("Server starting — log file: %s", log_file) - - config_path = project_dir / "dsagt_config.yaml" - from dsagt.session import resolve_env_vars - - config = resolve_env_vars(yaml.safe_load(config_path.read_text())) - - emb_config = config["embedding"] - - from dsagt.observability import init_tracing, configure_litellm_retries - - init_tracing( - "dsagt-registry-server" - ) # session_id picked up from DSAGT_SESSION_ID env - configure_litellm_retries() - - # The KB is optional for the registry server — most tools (save_tool_spec, - # get_registry, read_file, run_command, etc.) work without it. Only - # search_registry and search_skills need the KB for semantic search; - # they return a clear error if the KB is None. - backend = (emb_config.get("backend") or "local").lower() - # Cross-backend leakage guard: see the matching block in - # knowledge_server.py for the rationale. In short: when - # ``embedding.backend`` is ``local`` but the resolved model name is - # an OpenAI-style alias (no slash), drop the override so we fall - # back to the LocalEmbeddingClient default rather than 404 from HF. - raw_model = (emb_config.get("model") or "").strip() - embedder_kwargs: dict = {} - if raw_model and not raw_model.startswith("${"): - looks_hf = "/" in raw_model - if backend == "local" and not looks_hf: - log.warning( - "Ignoring embedding.model=%r for backend=local (does not " - "look like a HuggingFace identifier). Falling back to the " - "LocalEmbeddingClient default.", - raw_model, - ) - else: - embedder_kwargs["model"] = raw_model - if backend == "local": - kb_available = True - else: # backend == "api" - api_key = emb_config.get("api_key") or "" - kb_available = ( - api_key and not api_key.startswith("${") and emb_config.get("base_url") - ) - embedder_kwargs.update( - { - "base_url": emb_config.get("base_url") or "", - "api_key": api_key, - } - ) - - kb = None - if kb_available: - kb = KnowledgeBase( - index_dir=project_dir / "kb_index", - default_embedder=backend, - default_index=config["knowledge"]["vector_db"], - embedder_kwargs=embedder_kwargs, - ) - # Background-load the embedder so the model is ready when the - # agent's first search_registry / save_tool_spec call lands. - # See knowledge_server for the same rationale. - kb.preload_default_embedder() - - registry = ToolRegistry( - source_tools_dir=None, - runtime_dir=str(project_dir), - kb=kb, - ) - - skill_reg = SkillRegistry( - source_skills_dir=None, - runtime_dir=str(project_dir), - kb=kb, - ) - - # Bundled tools/skills are pre-embedded in the shared - # ~/dsagt-projects/kb_index/ by ``dsagt setup-kb`` (or by the auto-bootstrap - # in ``dsagt start``) and COPIED into the project's kb_index by - # ``setup_runtime_kb`` before either MCP server spawns. This server - # does no embedding work for bundled content at startup; agent's - # save_tool_spec / save_skill incur a single embed at save time. - - server = create_registry_server(registry, kb, skill_reg) - asyncio.run(_run_stdio(server, "registry")) - - -if __name__ == "__main__": - main() +# +# There is no standalone ``dsagt-registry-server`` entry point any more. The +# registry tools run inside the merged ``dsagt-server`` (see +# ``dsagt.commands.dsagt_server``), which composes +# :func:`_registry_tools_and_handlers` with the knowledge server's tools under +# one MCP ``Server`` + one shared ``KnowledgeBase``. ``create_registry_server`` +# above is retained as a standalone, test-facing constructor. diff --git a/src/dsagt/commands/setup_core_kb.py b/src/dsagt/commands/setup_core_kb.py index 0fa493b..ce81737 100644 --- a/src/dsagt/commands/setup_core_kb.py +++ b/src/dsagt/commands/setup_core_kb.py @@ -422,10 +422,8 @@ def run_setup_kb(args): args.index_dir.mkdir(parents=True, exist_ok=True) from dsagt.knowledge import KnowledgeBase from dsagt.registry import ( - SKILLS_COLLECTION, TOOLS_COLLECTION, ToolRegistry, - SkillRegistry, _parse_frontmatter, ) @@ -436,9 +434,12 @@ def run_setup_kb(args): embedder_kwargs=embedder_kwargs or {}, ) try: - # Bundled tools + skills: each spec file is a single chunk with - # rich metadata. Wipe-and-rebuild every run — there's no - # version sentinel, so the user controls when this happens. + # Bundled tools: each spec file is a single chunk with rich + # metadata. Wipe-and-rebuild every run — there's no version + # sentinel, so the user controls when this happens. Bundled + # *skills* are no longer indexed: every supported agent natively + # auto-discovers installed/bundled SKILL.md folders, so skill + # search covers only the external *catalog* tier below. current_version = _current_dsagt_version() tool_paths = [ @@ -446,18 +447,10 @@ def run_setup_kb(args): for p in sorted(ToolRegistry._PACKAGE_TOOLS_DIR.glob("*.md")) if _parse_frontmatter(p).get("name") ] - skill_dirs = [ - d - for d in sorted(SkillRegistry._PACKAGE_SKILLS_DIR.iterdir()) - if d.is_dir() - and (d / "SKILL.md").exists() - and _parse_frontmatter(d / "SKILL.md").get("name") - ] - for name in (TOOLS_COLLECTION, SKILLS_COLLECTION): - coll_dir = args.index_dir / name - if coll_dir.exists(): - shutil.rmtree(coll_dir) + coll_dir = args.index_dir / TOOLS_COLLECTION + if coll_dir.exists(): + shutil.rmtree(coll_dir) if tool_paths: tool_specs = [_parse_frontmatter(p) for p in tool_paths] @@ -477,23 +470,7 @@ def run_setup_kb(args): ], ) - if skill_dirs: - skill_specs = [_parse_frontmatter(d / "SKILL.md") for d in skill_dirs] - shared_kb.add_entries( - texts=[(d / "SKILL.md").read_text() for d in skill_dirs], - collection=SKILLS_COLLECTION, - metadatas=[ - { - "skill_name": s["name"], - "tags": ",".join(s.get("tags", [])), - "source": "bundled", - "dsagt_version": current_version, - } - for s in skill_specs - ], - ) - - print(" bundled tools + skills: indexed", flush=True) + print(" bundled tools: indexed", flush=True) # External skill catalog: clone + index the default source(s) so # ``search_skills`` can browse installable skills out of the box. diff --git a/src/dsagt/commands/skills_catalog.py b/src/dsagt/commands/skills_catalog.py index e59c083..3f8e7ef 100644 --- a/src/dsagt/commands/skills_catalog.py +++ b/src/dsagt/commands/skills_catalog.py @@ -21,12 +21,17 @@ from __future__ import annotations +import json import logging import re import shutil from pathlib import Path -from dsagt.registry import _parse_frontmatter, catalog_collection +from dsagt.registry import ( + CATALOG_COLLECTION_PREFIX, + _parse_frontmatter, + catalog_collection, +) from dsagt.session import REGISTRY_DIR logger = logging.getLogger(__name__) @@ -62,6 +67,14 @@ "subdir": None, "description": "Composio awesome-claude-skills — workflow skills for many SaaS apps.", }, + "genesis": { + "url": "https://gitlab.osti.gov/genesis/genesis-skills", + "branch": "main", + "subdir": "skills", + "description": "GENESIS skills (OSTI GitLab) — aggregated agent-skill " + "catalog: HPC (Slurm/PBS, Perlmutter/Aurora/Frontier), HuggingFace, " + "LangChain, OpenAI, Anthropic, plasma-sim, ModCon, and more (70+).", + }, } #: Shared, machine-global cache of cloned source repos (sibling of kb_index/). @@ -74,9 +87,11 @@ def resolve_source(source: str | dict) -> dict: - """Resolve a known-source name, a GitHub URL, or a full spec dict. + """Resolve a known-source name, a git URL (any host), or a full spec dict. - Returns a dict with at least ``url``; optional ``branch`` / ``subdir``. + A full ``http(s)://`` / ``git@`` URL works for any host (GitHub, GitLab, + …); the bare ``owner/repo`` shorthand assumes GitHub. Returns a dict with + at least ``url``; optional ``branch`` / ``subdir``. """ if isinstance(source, dict): if not source.get("url"): @@ -94,7 +109,8 @@ def resolve_source(source: str | dict) -> dict: return {"url": url, "branch": "main", "subdir": None} raise ValueError( f"Unknown skill source '{source}'. Use a known name " - f"({', '.join(sorted(KNOWN_SOURCES))}), a GitHub URL, or owner/repo." + f"({', '.join(sorted(KNOWN_SOURCES))}), a git URL (any host), " + f"or owner/repo (GitHub)." ) @@ -124,11 +140,18 @@ def persist_source_to_config(project_dir: str | Path, spec: dict) -> bool: def _repo_slug(url: str) -> str: - """Stable, collection-name-safe slug from a GitHub URL (``owner-repo``).""" + """Stable, collection-name-safe slug from a repo URL (``owner-repo``). + + Host-agnostic: the scheme and host are stripped so github.com, gitlab.*, + etc. all reduce to the ``owner/repo`` path. GitHub URLs keep the slug + they had before this generalization, so existing catalog collections do + not need rebuilding. + """ s = url.rstrip("/") - s = re.sub(r"^https?://github\.com/", "", s) - s = re.sub(r"^git@github\.com:", "", s) + s = re.sub(r"^https?://", "", s) # drop scheme + s = re.sub(r"^git@", "", s) # ssh form: git@host:owner/repo s = re.sub(r"\.git$", "", s).lower() + s = re.sub(r"^[^/:]+[/:]", "", s) # drop the host segment s = re.sub(r"[^a-z0-9]+", "-", s).strip("-") return s[:40] @@ -212,6 +235,23 @@ def sync_source( } +def _catalog_embed_text(spec: dict, fallback_name: str) -> str: + """Text embedded for catalog search: the frontmatter ``name`` + ``description`` + (+ ``tags``) only — *not* the SKILL.md body. + + Discovery is progressive-disclosure level 1: the description is authored to + say *what the skill does and when to use it*, which is exactly the match + target. Embedding the body would dilute that signal, and the embedder + truncates long input anyway (so a full SKILL.md is both incomplete and + misallocated). This also keeps the semantic backend ranking on the same + fields as the keyword fallback. + """ + name = spec.get("name") or fallback_name + desc = spec.get("description") or "" + tags = " ".join(spec.get("tags") or []) + return f"{name}: {desc} {tags}".strip() + + def index_catalog(skill_dirs: list[Path], slug: str, url: str, kb) -> int: """Wipe + rebuild source *slug*'s catalog collection from *skill_dirs*.""" collection = catalog_collection(slug) @@ -225,10 +265,11 @@ def index_catalog(skill_dirs: list[Path], slug: str, url: str, kb) -> int: skill_md = d / "SKILL.md" spec = _parse_frontmatter(skill_md) name = spec.get("name") or d.name - texts.append(skill_md.read_text()) + texts.append(_catalog_embed_text(spec, d.name)) metas.append( { "skill_name": name, + "description": (spec.get("description") or "")[:300], "tags": ",".join(spec.get("tags") or []), "source": f"catalog:{slug}", "source_url": url, @@ -275,6 +316,55 @@ def find_catalog_skill(name: str, *, cache_dir: Path = SKILL_SOURCES_DIR) -> Pat return next(iter(by_source.values())) +#: License / attribution files to preserve when installing a catalog skill. +_ATTRIBUTION_GLOBS = ( + "LICENSE*", + "NOTICE*", + "COPYING*", + "COPYRIGHT*", + "ATTRIBUTION*", +) + + +def _capture_attribution(src: Path, dest: Path, cache_dir: Path) -> list[str]: + """Preserve license/attribution when installing a (third-party) catalog skill. + + ``copytree`` already carries files *inside* the skill dir. A skill is often + governed by a per-subtree or repo-root ``LICENSE`` / ``NOTICE`` / + ``ATTRIBUTION`` that lives *outside* its own folder, so this also pulls those + from ancestor dirs up to the source repo root (which ``clone_github`` mirrors + into the cache root even for sparse ``subdir`` clones). Nearest ancestor + wins a filename collision; skill-local files (already in ``dest``) are never + overwritten. Always stamps a ``PROVENANCE.txt`` recording the source. + Returns the names of files captured from ancestors. + """ + src, dest, cache_dir = Path(src), Path(dest), Path(cache_dir) + try: + slug = src.relative_to(cache_dir).parts[0] + repo_root = cache_dir / slug + rel = src.relative_to(repo_root) + except ValueError: # src outside the cache (shouldn't happen) — degrade. + slug, repo_root, rel = src.parent.name, src.parent, Path(src.name) + + captured: list[str] = [] + node = src.parent + while True: + for pat in _ATTRIBUTION_GLOBS: + for f in sorted(node.glob(pat)): + if f.is_file() and not (dest / f.name).exists(): + shutil.copy2(f, dest / f.name) + captured.append(f.name) + if node == repo_root or node == node.parent: + break + node = node.parent + + (dest / "PROVENANCE.txt").write_text( + f"Installed by dsagt from catalog source: {slug}\n" + f"Source path in repo: {rel}\n" + ) + return captured + + def install_into_project( name: str, project_dir: str | Path, *, cache_dir: Path = SKILL_SOURCES_DIR ) -> dict: @@ -282,7 +372,9 @@ def install_into_project( The destination directory is named after the skill's frontmatter ``name`` (falling back to its source dir name) so it matches the invocable name in - native discovery. Returns ``{name, source_dir, dest_dir, action}``. + native discovery. Preserves upstream license/attribution (see + :func:`_capture_attribution`). Returns + ``{name, source_dir, dest_dir, action, attribution}``. """ src = find_catalog_skill(name, cache_dir=cache_dir) spec = _parse_frontmatter(src / "SKILL.md") @@ -293,10 +385,180 @@ def install_into_project( if dest.exists(): shutil.rmtree(dest) shutil.copytree(src, dest) + attribution = _capture_attribution(src, dest, cache_dir) return { "name": skill_name, "source_dir": str(src), "dest_dir": str(dest), "action": action, + "attribution": attribution, } + + +# --------------------------------------------------------------------------- +# SkillsCatalog — the catalog data plane (composition over KnowledgeBase) +# --------------------------------------------------------------------------- + + +class SkillsCatalog: + """The external-skill *catalog* behind one object. + + Composition over :class:`~dsagt.knowledge.KnowledgeBase`: it holds a KB + handle (the host server's existing instance → shared embedder, no second + model load) plus the clone-cache dir, and exposes the skill-domain ops — + ``sync`` / ``search`` / ``install`` / ``list_sources``. The skill-specific + behavior (frontmatter-indexed catalog collections, the no-embedder keyword + fallback over the clone cache) lives here; the vector store + embedder are + the shared KB. :class:`~dsagt.skill_discovery.SkillRouter` is a thin + render/MCP facade over this. + + Catalog tier only: installed/created skills are natively auto-discovered by + every supported agent, so they are never search candidates. ``search`` + covers the not-yet-installed catalog (which native discovery can't see) + plus the Cline-skills-disabled / no-embedder case via the keyword scorer. + """ + + def __init__(self, *, kb=None, cache_dir: Path | None = None): + self._kb = kb + self._cache_dir = cache_dir # default resolved lazily + + @property + def has_kb(self) -> bool: + return self._kb is not None + + def _resolved_cache_dir(self) -> Path: + return Path(self._cache_dir or SKILL_SOURCES_DIR) + + # -- write ops (delegate to the module functions) ------------------------ + + def sync(self, source, *, force: bool = False) -> dict: + """Clone + frontmatter-index a source into its catalog collection.""" + return sync_source( + source, kb=self._kb, cache_dir=self._resolved_cache_dir(), force=force + ) + + def install(self, name: str, project_dir) -> dict: + """Copy a catalog skill into ``/skills//`` (+ attribution).""" + return install_into_project( + name, project_dir, cache_dir=self._resolved_cache_dir() + ) + + # -- backend selection --------------------------------------------------- + + def synced_collections(self) -> list[str]: + """The ``skills_catalog__*`` collections currently indexed in the KB.""" + if self._kb is None: + return [] + return [ + c for c in self._kb.collections if c.startswith(CATALOG_COLLECTION_PREFIX) + ] + + def search(self, query=None, *, top_k: int = 8, tag=None) -> list[dict]: + """Rank catalog candidates → normalized hit dicts (data, not rendered). + + ChromaDB semantic search when a KB exists, else the Genesis-derived + keyword scorer over the clone cache. + """ + if self._kb is not None: + return self._select_kb(query, top_k, tag) + return self._select_keyword(query, top_k, tag) + + def _select_kb(self, query, top_k: int, tag) -> list[dict]: + collections = self.synced_collections() + fetch_k = top_k * 3 if tag else top_k + hits: list[dict] = [] + for coll in collections: + try: + hits.extend( + self._kb.search( + query=query or "skill", collection=coll, top_k=fetch_k + ) + ) + except (FileNotFoundError, KeyError, ValueError): + continue + out = [] + for r in hits: + chunk = r.get("chunk", {}) + meta = chunk.get("metadata", {}) + out.append( + { + "name": meta.get("skill_name", "unknown"), + "summary": (meta.get("description") or chunk.get("text", "") or "")[ + :200 + ], + "source": meta.get("source", ""), + "tags": meta.get("tags", ""), + "score": r.get("score", 0.0), + } + ) + if tag: + out = [h for h in out if tag in (h["tags"] or "")] + out.sort(key=lambda h: h["score"], reverse=True) + return out[:top_k] + + def _candidate_skills(self) -> list[dict]: + """Cached-catalog skills as scorer-ready dicts (no KB needed).""" + cands: list[dict] = [] + cache = self._resolved_cache_dir() + if cache.exists(): + for slug_dir in sorted(p for p in cache.iterdir() if p.is_dir()): + for d in _discover_skill_dirs(slug_dir): + spec = _parse_frontmatter(d / "SKILL.md") + if spec.get("name"): + cands.append( + { + "name": spec["name"], + "description": spec.get("description", ""), + "tags": ",".join(spec.get("tags", []) or []), + "source": f"catalog:{slug_dir.name}", + } + ) + return cands + + def _select_keyword(self, query, top_k: int, tag) -> list[dict]: + from dsagt import skill_keyword # lazy: keep import graph light + + cands = self._candidate_skills() + if tag: + cands = [c for c in cands if tag in (c["tags"] or "")] + if not query: + picks = cands[:top_k] + return [ + {**c, "summary": (c["description"] or "")[:200], "score": 0.0} + for c in picks + ] + ranked = skill_keyword.rank_skills(query, cands, top_k=top_k) + return [ + {**c, "summary": (c["description"] or "")[:200], "score": sc} + for c, sc in ranked + ] + + # -- source view --------------------------------------------------------- + + def list_sources(self) -> list[dict]: + """Known sources + synced flag + indexed count (one source of truth).""" + synced = set(self.synced_collections()) + out = [] + for name, spec in KNOWN_SOURCES.items(): + coll = catalog_collection(_repo_slug(spec["url"])) + is_synced = coll in synced + out.append( + { + "name": name, + "url": spec["url"], + "description": spec.get("description", ""), + "synced": is_synced, + "indexed": self._indexed_count(coll) if is_synced else 0, + } + ) + return out + + def _indexed_count(self, collection: str) -> int: + if self._kb is None: + return 0 + ids = Path(self._kb.index_dir) / collection / "chroma_ids.json" + try: + return len(json.loads(ids.read_text())) + except (FileNotFoundError, ValueError, OSError): + return 0 diff --git a/src/dsagt/registry.py b/src/dsagt/registry.py index a615672..68d7d48 100644 --- a/src/dsagt/registry.py +++ b/src/dsagt/registry.py @@ -32,13 +32,17 @@ #: they can be evicted and refreshed on dsagt upgrade without touching #: agent-registered entries. TOOLS_COLLECTION = "tools" +#: Legacy installed-skills collection. No longer written or read: installed +#: skills are natively auto-discovered by every supported agent, so skill +#: search covers only the *catalog* tier below. Kept as a name for back-compat +#: and ``dsagt info`` display of any pre-existing index. SKILLS_COLLECTION = "skills" #: External skill catalogs (fetched from GitHub repos) live in their own #: per-source collections named ``skills_catalog__``. Keeping each #: source in its own collection lets a re-sync drop+rebuild one source's -#: directory without disturbing bundled/registered skills (the ``skills`` -#: collection) or other catalogs — no delete-by-metadata primitive needed. +#: directory without disturbing other catalogs — no delete-by-metadata +#: primitive needed. CATALOG_COLLECTION_PREFIX = "skills_catalog__" @@ -499,9 +503,11 @@ def save_skill( ``{relative_path: contents}`` for additional files the skill wants in its directory (templates, schemas, etc.). - Returns "added" or "updated". Indexes the resulting SKILL.md - into ``registered_skills`` via ``_index_skill`` if a KB is - configured — symmetric with ``ToolRegistry.save_tool``. + Returns "added" or "updated". Does **not** index into a KB: + saved skills land in ``/skills/`` where every supported + agent natively auto-discovers them, so search only covers the + not-yet-installed *catalog* tier (see ``SkillRouter``). The old + ``skills`` collection is no longer read by anything. """ name = spec.get("name") if not name: @@ -530,9 +536,6 @@ def save_skill( target.parent.mkdir(parents=True, exist_ok=True) target.write_text(contents) - if self._kb: - self._index_skill(spec, skill_md) - return action def _skill_md_path(self, name: str) -> Path | None: @@ -557,38 +560,3 @@ def get_skill_content(self, name: str) -> str | None: """Get the full SKILL.md content for a skill.""" path = self._skill_md_path(name) return path.read_text() if path is not None else None - - def _index_skill(self, spec: dict, skill_md: Path) -> None: - """Index a skill into the ``skills`` KB collection. - - Errors propagate to the caller — see _index_tool for the rationale. - """ - text = skill_md.read_text() - metadata = { - "skill_name": spec["name"], - "tags": ",".join(spec.get("tags", [])), - "source": "registered", # vs "bundled" - } - self._kb.add_entries( - texts=[text], - collection=SKILLS_COLLECTION, - metadatas=[metadata], - ) - - def reindex_all(self) -> int: - """Reindex project-local skills into ``registered_skills``. - - Bundled skills are NOT indexed here — they live in the shared - ``bundled_skills`` collection (see ToolRegistry.reindex_all - docstring for the same architecture). - """ - if not self._kb: - return 0 - count = 0 - for skill_dir in self._project_skill_dirs(): - skill_md = skill_dir / "SKILL.md" - spec = _parse_frontmatter(skill_md) - if spec.get("name"): - self._index_skill(spec, skill_md) - count += 1 - return count diff --git a/src/dsagt/skill_discovery.py b/src/dsagt/skill_discovery.py new file mode 100644 index 0000000..fe167c7 --- /dev/null +++ b/src/dsagt/skill_discovery.py @@ -0,0 +1,110 @@ +"""SkillRouter — the thin render/MCP facade over the skill *catalog*. + +The catalog data plane (clone cache + ChromaDB collections + backend +selection + the four ops) lives in +:class:`dsagt.commands.skills_catalog.SkillsCatalog`. ``SkillRouter`` adds only +the presentation concerns that the MCP handlers and the CLI share: rendering a +ranked hit list into the ``search_skills`` string, the empty-result message, and +the exact-``skill_name`` lookup (which needs the installed-skill registry, not +the catalog). + +Construct it from the same inputs at every call site (MCP ``search_skills``, CLI +``skills search/list``) so policy can't diverge between them — or hand it a +prebuilt :class:`SkillsCatalog` via ``catalog=`` so a server that already owns a +shared KB reuses one catalog instance. + +Skill *materialization* (mirroring installed skills into each agent's native +skills directory) lives in the agent layer (``AgentSetup.setup_skills``), not +here: every supported agent (claude/codex/goose/cline/roo) natively +auto-discovers ``SKILL.md`` folders, so there is no agent-facing disclosure tier +for the router to own. ``search_skills`` exists for the *catalog* tier (skills +not yet installed, which native discovery can't see) plus the no-embedder +keyword fallback. + +See ``design-notes/genesis-skills-comparison.md`` §7 and +``design-notes/skills-catalog-server-merge.md`` §1. +""" + +from __future__ import annotations + +import logging + +from dsagt.commands.skills_catalog import SkillsCatalog + +logger = logging.getLogger(__name__) + + +def _where_label(source: str) -> str: + """Human tag for a hit's origin, matching the legacy search output.""" + if source in ("bundled", "registered", "installed"): + return " [installed]" + if source.startswith("catalog:"): + return " [catalog · install_skill to add]" + return "" + + +class SkillRouter: + """Renders catalog discovery for the MCP ``search_skills`` tool + the CLI.""" + + def __init__(self, *, kb=None, skill_registry=None, cache_dir=None, catalog=None): + self._catalog = ( + catalog + if catalog is not None + else SkillsCatalog(kb=kb, cache_dir=cache_dir) + ) + self._reg = skill_registry + + # -- rendering ----------------------------------------------------------- + + def _render_search(self, hits: list[dict]) -> str: + lines = [] + for h in hits: + lines.append( + f"- **{h['name']}**{_where_label(h['source'])} " + f"(score: {h['score']:.2f})\n {h['summary']}" + ) + return f"Found {len(hits)} skill(s):\n\n" + "\n\n".join(lines) + + def _empty_message(self) -> str: + if not self._catalog.synced_collections() and self._catalog.has_kb: + return ( + "No catalog skills found. No external skill catalog is synced " + "yet — search covers the catalog (skills you can install), since " + "installed skills are already natively discoverable. Call " + "list_skill_sources() to see available sources, then " + "add_skill_source(source=...) to sync one before searching again." + ) + return "No catalog skills found matching the query." + + # -- public API ---------------------------------------------------------- + + def search(self, query=None, *, top_k: int = 8, tag=None, skill_name=None) -> str: + """Stage B. Select + render. Stateless — no session/exposure tracking.""" + if skill_name: + import yaml + + if self._reg is None: + return f"No skill named '{skill_name}'." + spec = self._reg.get_skill(skill_name) + if spec: + return f"Found skill '{skill_name}':\n\n" + yaml.dump( + spec, default_flow_style=False, sort_keys=False + ) + return f"No skill named '{skill_name}'." + + hits = self._catalog.search(query, top_k=top_k, tag=tag) + if not hits: + return self._empty_message() + return self._render_search(hits) + + def sync(self, source, *, force: bool = False) -> dict: + """Stage A passthrough — see :meth:`SkillsCatalog.sync`.""" + return self._catalog.sync(source, force=force) + + def install(self, name: str, project_dir) -> dict: + """Stage C passthrough — see :meth:`SkillsCatalog.install`.""" + return self._catalog.install(name, project_dir) + + def list_sources(self) -> list[dict]: + """Stage A view passthrough — see :meth:`SkillsCatalog.list_sources`.""" + return self._catalog.list_sources() diff --git a/src/dsagt/skill_keyword.py b/src/dsagt/skill_keyword.py new file mode 100644 index 0000000..2542c22 --- /dev/null +++ b/src/dsagt/skill_keyword.py @@ -0,0 +1,101 @@ +"""Zero-dependency keyword token-overlap scorer for skill discovery. + +A faithful reimplementation (not an import) of the Genesis Skills +``skill-search`` engine (``skill_search/catalog.py``: ``_score_skill`` / +``rank_skills``), Apache-2.0, gitlab.osti.gov/genesis/genesis-skills. This is +the fallback ranker used by :class:`dsagt.skill_discovery.SkillRouter` when no +embedder / KB is configured: keyword overlap only, stdlib only, deterministic. + +Scoring (per skill, against a query) — matching Genesis exactly: + +* +2 for each query token that also appears in the skill **name** +* +1 for each query token that also appears in the **description** +* then **at most one** substring bonus (mutually exclusive, in priority order): + +6 if the query equals the name, else +4 if it is a substring of the name, + else +2 if it is a substring of the description + +Tokens are casefolded ``\\w+`` runs with hyphens split, single-character tokens +and stopwords dropped. Ties break by name (ascending); below ``min_score`` are +dropped. +""" + +from __future__ import annotations + +import re + +#: Stopword set — kept identical to Genesis so ranking parity holds. +_STOPWORDS = frozenset( + { + "a", + "an", + "and", + "as", + "at", + "be", + "for", + "from", + "if", + "in", + "into", + "is", + "it", + "of", + "on", + "or", + "please", + "the", + "this", + "to", + "use", + "using", + "with", + } +) + +_TOKEN_RE = re.compile(r"\w+", flags=re.UNICODE) + + +def _tokens(text: str) -> set[str]: + """Casefolded word tokens (hyphens split), single-char + stopwords removed.""" + normalized = (text or "").casefold().replace("-", " ") + return { + t for t in _TOKEN_RE.findall(normalized) if len(t) > 1 and t not in _STOPWORDS + } + + +def score_skill(query: str, name: str, description: str) -> float: + """Token-overlap score of one skill against *query* (0.0 = no match).""" + qtokens = _tokens(query) + normalized_query = (query or "").casefold().strip() + if not qtokens and not normalized_query: + return 0.0 + + score = 2 * len(qtokens & _tokens(name)) + len(qtokens & _tokens(description)) + + if normalized_query: + name_l = (name or "").casefold() + if normalized_query == name_l: + score += 6 + elif normalized_query in name_l: + score += 4 + elif normalized_query in (description or "").casefold(): + score += 2 + return float(score) + + +def rank_skills( + query: str, skills, top_k: int | None = 8, min_score: int = 1 +) -> list[tuple[dict, float]]: + """Rank *skills* (dicts with ``name`` + ``description``) against *query*. + + Returns ``[(skill, score), ...]`` for skills scoring at least *min_score*, + sorted by score descending then name ascending, truncated to *top_k* (all + when *top_k* is ``None``). + """ + scored: list[tuple[dict, float]] = [] + for s in skills: + sc = score_skill(query, s.get("name", ""), s.get("description", "")) + if sc >= min_score: + scored.append((s, sc)) + scored.sort(key=lambda kv: (-kv[1], (kv[0].get("name") or ""))) + return scored[:top_k] if top_k is not None else scored diff --git a/src/dsagt/skills/datacard-generator/SKILL.md b/src/dsagt/skills/datacard-generator/SKILL.md deleted file mode 100644 index 3a2c06a..0000000 --- a/src/dsagt/skills/datacard-generator/SKILL.md +++ /dev/null @@ -1,88 +0,0 @@ ---- -name: generating-datacards -description: "Generates MODCON Datacard v1 documentation for scientific datasets by introspecting a directory and filling a structured template. Use when the user asks to create a datacard, dataset card, dataset documentation, dataset metadata, document a dataset, or prepare a dataset for sharing. Supports three readiness levels: Level 1 (Discoverable), Level 2 (Interoperable & Reusable), Level 3 (Understandable & Trustworthy)." ---- - -# Generating Datacards - -Introspect a dataset directory to auto-populate a MODCON Datacard v1, then prompt the user for anything that couldn't be inferred. - -## Workflow - -Copy this checklist and check off steps as you go: - -``` -Progress: -- [ ] 1. Gather context (path + level) -- [ ] 2. Introspect dataset directory -- [ ] 3. Auto-fill the data card -- [ ] 4. Prompt for missing fields -- [ ] 5. Generate output file -- [ ] 6. Review summary -``` - -### 1. Gather Context - -Ask the user: -- **Dataset path** — directory to document -- **Readiness level** — choose one: - - **Level 1 — Discoverable**: identification, description, file structure, keywords - - **Level 2 — Interoperable & Reusable**: Level 1 + contacts, access, license, provenance, authors, related resources - - **Level 3 — Understandable & Trustworthy**: Level 2 + schema, quality, integrity, AI/ML, variable docs - -### 2. Introspect the Dataset Directory - -See [reference/introspection-commands.md](reference/introspection-commands.md) for exact commands to run for file structure, schema extraction, and existing metadata files. - -### 3. Auto-Fill the Data Card - -Read `references/datacard_template_v1.md`. Populate fields using this decision table: - -| Field | Auto-fill if… | Otherwise | -|-------|--------------|-----------| -| `datacard_creation.created_date` | Always | — | -| `datacard_creation.creation_method` | Always → `"hybrid"` | — | -| `dataset_info.data_formats` | File extensions found | Prompt | -| `dataset_info.modalities` | File types recognized | Prompt | -| `dataset_info.features` | Column headers extracted | Prompt at Level 2+ | -| `dataset_info.splits` | train/test/val dirs found | Leave empty | -| `dataset_counts` | File/record counts available | Prompt | -| `dataset_storage` | `du` output available | Prompt | -| `title` / `name` | README or metadata file found | Prompt | -| Description | README found | Prompt | -| Keywords | Metadata files found | Prompt at Level 1+ | -| License | LICENSE file found | Prompt at Level 2+ | -| Authors / contributors | CITATION.cff found | Prompt at Level 2+ | -| Citation | .bib or CITATION.cff found | Prompt at Level 2+ | - -Cross-check these fields for consistency between YAML frontmatter and markdown body: `title`/`name`, `authors`/`contributors`, `license`, `dataset_info.data_formats`, key dates. - -### 4. Prompt for Missing Fields - -Present auto-discovered values for confirmation, then ask for unfilled fields. Ask **3–5 at a time**. See [reference/field-prompts.md](reference/field-prompts.md) for the full per-level field list. - -### 5. Generate the Data Card - -- **Filename**: `modcon_datacard_.md` -- **Location**: save inside `/` by default; ask if the user prefers elsewhere -- Replace all `[!TODO]`, ``, ``, `` — none should appear in output -- Set `dataset_readiness.level` to chosen level -- Unprovided fields: `"N/A"` in YAML, brief "Not provided." in markdown body - -### 6. Review Summary - -Present: -- ✅ Auto-populated fields -- ✏️ User-provided fields -- ⬜ Empty/N/A fields (with reason) -- 💡 Suggestions for improvement - -Ask if the user wants to revise any sections. - -## References - -- **Template**: `references/datacard_template_v1.md` -- **Datasheet companion**: `references/datasheet_template.md` — consult if user mentions "datasheet" questions -- **Introspection commands**: [reference/introspection-commands.md](reference/introspection-commands.md) -- **Per-level field prompts**: [reference/field-prompts.md](reference/field-prompts.md) -- **Lookup tables** (OSTI codes, sensitivity tiers): [reference/lookup-tables.md](reference/lookup-tables.md) \ No newline at end of file diff --git a/src/dsagt/skills/datacard-generator/reference/datacard_template_v1.md b/src/dsagt/skills/datacard-generator/reference/datacard_template_v1.md deleted file mode 100644 index 3982f47..0000000 --- a/src/dsagt/skills/datacard-generator/reference/datacard_template_v1.md +++ /dev/null @@ -1,498 +0,0 @@ ---- -#YAML Metadata to support datacard metadata: (Instructions follow) -#----------------Datacard Information-- This section provides information about this datacard and its contents -datacard_info: - filename: modcon_datacard_${SNAKE_CASE_DATASET_NAME}.md # Follow this filename convention and enter the filename here - id: # PID assigned to this datacard if used. - type: #DOI | HANDLE | URL | LOCAL | OTHER - value: - datacard_access: #describe any access restrictions for this datacard, or "None - publicly accessible" - datacard_creation: - created_date: "${CREATED_DATE_YYYY_MM_DD}" # required - update_date: "${UPDATE_DATE_YYYY_MM_DD}" # add updates as needed - creation_method: #manual | automated | hybrid - created_by: [] # Contacts for this datacard. Include at least one (datacard_contact_person) or organizational contact (datacard_contact_organization) with email. - - datacard_contact_person: - name: - orcid: - email: - affiliation: - - datacard_contact_organization: - organization_name: - ror_id: - email: - datacard_template_version: 1.0 - language: en - -#-------------Data Identification-- Required. This section contains the information needed to link this datacard to the datasets it describes: -data_identifiers: - name: "${DATASET_NAME}" #provide a single, human readable name even if this datacard describes a collection of data. Ensure alignment with the datacard filename. - dataset_id: # Identify the dataset(s) described by this datacard. At least 1 identifier is required. Persistent, unique identifiers (PIDs) are preferred. - type: # DOI | HANDLE | URL | LOCAL | OTHER - value: - -#----- Level 1: Discoverable----- -#[Required Level1]: -# Identification -title: "${DATASET_NAME}" -id: # ID of the datacard if known. Otherwise this will be provided by the system. -project: # required - project name or identifier that the dataset is associated with - -#--- Data files/objects in dataset - -# for published datasets, include one or more dois. For unpublished datasets, provide a landing page url if available, or other identifier such as a local identifier or handle. -identifiers: [] # [required level1]. At least 1 identifier is required. - - type: # DOI | HANDLE | URL | LOCAL | OTHER - value: - - -#[Optional Level1]: -#--- Dataset Structure / Characteristics--- -dataset_info: - modalities: [] # e.g., ["tabular","image","time-series"] - data_formats: [] - features: [] # list of variables or features in the dataset. - splits: [] # e.g., ["train", "test", "validation"] - dataobject_type: dataset #Type of digital object (dataset, aiagent, eval, framework, model, etc.) - dataset_type: #See OSTI DOE Data Explorer Types - -#**OSTI DOE Data Explorer Dataset Types**: -#| Code | Type | Description | -#|------|------|-------------| -#| `GD` | Genome/Genetic Data | DNA/RNA sequences, genetic markers, genomic annotations | -#| `IM` | Image | Photographs, scans, visualizations, microscopy | -#| `ND` | Numeric Data | Measurements, time series, tabular data, sensor readings | -#| `SM` | Specialized Mix | Multiple data types combined | -#| `FP` | Figure/Plot | Charts, graphs, plots as primary deliverable | -#| `I` | Interactive Resource | Web applications, interactive visualizations, dashboards | -#| `MM` | Multimedia | Audio, video, combined media | -#| `MD` | Model | Computational models, simulations, trained ML models | -#| `AS` | Automated Software | Scripts, analysis pipelines, workflows | -#| `IP` | Instrumentation and Protocols | Experimental protocols, instrument specifications | -#| `IG` | Integrated Genomic Resources | Combined genomic databases and tools | - -dataset_readiness: - level: # 1 | 2 | 3 - evaluated_against: "Genesis Dataset Readiness Model v1.0" - evaluated_at: - evaluated_by: - confidence: # high | medium | low - - -#--- Lifecycle / Governance--- -release_status: # draft | approved | published | deprecated - -review_process: - review_purpose: # a short description of why is the data is being reviewed (e.g., "release", "partner sharing", "IRB",... ) - review_status: # "submitted" | "pending" | "approved" | "declined" | - review_institution: - name: # name of institution conducting the review - ror_id: # ROR ID of institution conducting the review - review_comments: - - -#------ Level 2: Interoperable and Reusable------- - -#[Required Level2]: -#--- Contacts -contact_point: - entity: - type: person # person - person: - given_name: - family_name: - orcid: - email: - affiliation: - entity: - type: organization # organization - organization: - name: - ror_id: - -additional_contacts: [] - -#--- Access, Permissions and Policy -access_policy: - # sensitivity_tier classification: - # tier0_openScience - # tier1_controlledResearch - # tier2_proprietary - # tier3_sensitive_exportControlled - # tier4_regulated_personal - # tier5_classified - sensitivity_tier: - access_level: # NOTE: currently only accepting "open", open | restricted | controlled - required NOTE: currently only accepting "open" - authorization: # none | accountRequired | userAgreement | dataUseAgreement | sponsorApproval | exportControlReview | irbApproval | other - policy_url: - policy_text: - -#[IF Applicable]: -cui_markings: # see CUI documentation -distribution_statement: #examples are: "Distribution A - Approved for public release"; "Distribution D - DoD only"; "Internal DOE use only" -handling_instructions: #examples are "No foreign dissemination" or "Export-controlled handling required" - -security_marking: - classification: # unclassified | cui | classified | confidential | Secret | TS | other - cui_marking: - distribution_statement: - handling_instructions: - declassification: - review_date: - authority: - -#--- Rights & License--- -license: - spdx_id: # SPDX ID is a standardized license identifier (e.g., CC-BY-4.0, MIT, Apache-2.0) - name: - link: - -additional_licenses: [] - -#[Optional Level2]: -# Domain and Purpose -categorization: - science_domain: "${SCIENCE_DOMAIN}" # required - high level scientific domain or discipline that the dataset is associated with, e.g., physics, chemistry, materials science, etc. - modalities: [] # e.g., ["tabular","image","time-series"] - task_category: [] # ML classification - task_subcategory: [] # ML subcategory classification - -# Resources -originating_research_organization: - entity: - type: organization # organization - organization: - name: - ror_id: - -facilities: [] -# list of facilities (as entities, type: organization) where the dataset was collected, processed, stored, or accessed. Examples include research labs, data centers, cloud platforms, supercomputing facilities, etc. - -fundings: -# list of funding sources that supported the creation, maintenance, or sharing of the dataset. Examples include grants, contracts, in-kind support, etc. - - funding: - award_number: - program: - funder: - entity: - type: organization # organization - organization: - name: - ror_id: - -#--- Dataset Responsibility & Credit -dataset_authors: - - entity: # at least 1 is required - type: person # person - person: - given_name: - family_name: - orcid: - email: - affiliation: - entity: - type: organization # organization - organization: - name: - ror_id: - role: creator - -dataset_contributors: [] # Acknowlege other contributors beyond dataset authors and include them as entities of type person or organization. - -#--- Stewardship & Maintenance--- -stewardship: - level: # projectManaged | repositoryManaged - -maintenance: - update_frequency: # none | ad_hoc | monthly | quarterly | annually | other - -# Provenance -#--- Dataset Provenance--- -dataset_provenance: - was_generated_by: - source_data: - processing_steps: - instrumentation: - simulation_details: - -#--- Related Resources (Optional but recommended) -related_resources: - related_datasets: [] - # - dataset_name: - # identifiers: - # - type: DOI | URL | LOCAL | OTHER - # value: - # relationship: isDerivedFrom | isBasedOn | isPartOf | hasPart | references | other - publications: - dois: [] - arxiv: [] - urls: [] - software: - # - name: - # version: - # identifiers: - # - type: DOI | URL | LOCAL | OTHER - # value: - # relationship: isDerivedFrom | isBasedOn | isPartOf | hasPart | references | other - aimodels: [] - # - name: #if available, provide the name of the AI model - # version: - # date accessed: - # identifiers: - # - type: DOI | URL | LOCAL | OTHER - # value: - # relationship: isDerivedFrom | isBasedOn | isPartOf | hasPart | references | other -#-- Methods -#--- Dates -dates: - data_collection_start: - data_collection_end: - issued: - modified: - - -#------ Level 3: Understandable & Trustworthy------- - -#[If Applicable Level3]: -#--- Semantic / Schema -semantic_layer: - # Describe formal schema / ontology alignment if available - # Leave blank if none exists (acceptable at Level 1 or Level 2) - schema_url: - ontology_alignment: [] - semantic_context: [] - controlled_vocabularies: [] - -#--- Data Quality -data_quality: - completeness: - known_issues: - validation_methods: - noise_characteristics: - uncertainty_notes: - -#--- Integrity / Fixity -integrity: - checksum_available: # true | false - checksum_type: - fixity_policy: - versioning_strategy: - -#--- AI / ML Usage -ai_usage: - ai_ready: # true | false | conditional - training_use_allowed: # true | false | conditional - inference_use_allowed: # true | false | conditional - restrictions: - bias_risks: - safety_considerations: - human_review_required: # true | false - -#---- System Level Info:---- - -#--- Dataset Scale -dataset_counts: - value: - category: # samples | files | records | timesteps | other - unit: - -dataset_storage: - compressed_bytes: - unpacked_bytes: - -#--- Repository-managed access endpoints--- -repository_access: - populated_by_repository: true - distributions: [] - # Example: - # - distribution: - # access_url: {} - # download_url: - # format: - # byte_size: - # checksum: - # type: [] - # items: [] - data_services: [] - # Example: - # - data_service: - # type: api | s3 | other - # additional_properties: false - # properties: - # endpoint: {} - # service_type: {} - # auth_hint: {} ---- - -### Instructions - - - - - - - - - - - -# Datacard for ${DATASET_NAME} -**Last Updated**: [!TODO] - -**Dataset Readiness Level:** - -### Machine Usability Snapshot -| Aspect | Status | -|--------|--------| -| AI Ready | Yes/No/Conditional| -| License Clarity | Yes/No| -| Machine Access | Yes/No| -| Checksum / Fixity | Yes/No| -| Semantic Context | Yes/No| - - -# ---- Level 1: Discoverable ---- ---- -## Identification - - -### Files & Structure -[!TODO] [required level1] - ---- -## Description - -### Dataset Description [required] -[!TODO] - -### Keywords [strongly recommended] -[!TODO] - -### Citation -[!TODO] - ---- - -# ---- Level 2: Interoperable and Reusable ---- - -### Sharing & Access -[!TODO] - -### Security / Marking Considerations -[!TODO] - ---- -### Access and Permissions -[!TODO] - -### Access conditions -[!TODO] - -### Release review process -[!TODO] - ---- -## Context - -### Domain and Purpose -[!TODO] - -### Resources used, including funding and facilities, to create the dataset -[!TODO] - ---- -## Provenance - -### Developed by -[!TODO] - -### Contributed by -[!TODO] - ---- -## Related Resources - -### Related datasets, standards, metadata, and ontologies -[!TODO] - -### Related publications -[!TODO] - -### Related software -[!TODO] - -### Related ai model -[!TODO] - ---- -## Methods - -### Dataset generation, collection, and procedures -[!TODO] - -### Maintenance & Updates -[!TODO] - - -# ---- Level 3: Understandable & Trustworthy ---- - -### Data Characteristics -[!TODO] - -### Data Quality & Limitations -[!TODO] - -### Related Schemas or Ontologies -[!TODO] - -### List of variable name(s), description(s), unit(s), and value labels for each variable in the dataset/file. -[!TODO] - -For example: -| Variable Name | Description | Unit | Value Labels | -|---------------|---------------------------|-----------|-----------------------------| -| temp | Temperature measurement | Celsius | N/A | -| status | Operational status | N/A | 0 = Off, 1 = On | - -### Codes used for missing data -[!TODO] - -For example: -| Code | Description | -|------|---------------------------| -| -999 | Data not collected | -| -888 | Measurement error | - -### Specialized formats or other abbreviations used -[!TODO] - -### Example of the contents -[!TODO] - -### Data Processing -[!TODO] - -### Software used to preprocess/ clean/ label the data -[!TODO] - -## Integrity & Versioning -[!TODO] - -## Semantic / Schema Information -[!TODO] - -## AI / Machine Learning Considerations -[!TODO] - ---- - -## Additional Information -[!TODO] - ---- \ No newline at end of file diff --git a/src/dsagt/skills/datacard-generator/reference/datasheet_template.md b/src/dsagt/skills/datacard-generator/reference/datasheet_template.md deleted file mode 100644 index c69afe9..0000000 --- a/src/dsagt/skills/datacard-generator/reference/datasheet_template.md +++ /dev/null @@ -1,320 +0,0 @@ ---- -language: -- en # ISO language tag -datasheet_version: 0.1.0 # version of the GENESIS datasheet template -name: Datasheet for {DATASET_NAME} # name of the datasheet -tags: -- project:genesis # include on all GENESIS project datasets -- science:lightsource # what kind of science is this for (e.g., materials, biology, lightsource, fusion, climate, etc.) -- keywords: [] # keywords associated with the dataset -- risk:general # indicates level of risk review {general, reviewed, restricted} ---- -# Datasheets for Datasets: Contextualizing Scientific Data - -Documenting a scientific dataset in a datasheet requires careful consideration of scope and resolution. -Scientific data often exists as part of a larger ecosystem of the scientific record: a single experiment may produce multiple datasets, or a dataset may be split into subsets for different analyses. -Deciding at what level to create a datasheet is not always straightforward—too granular, and you risk an unmanageable number of datasheets; too broad, and important details may be lost. -Consider how the dataset will be used and cited, who the intended consumers are (e.g., researchers, search engines, AI systems), and what level of documentation will provide meaningful transparency without unnecessary duplication. -Keep in mind that datasheets are not necessarily one-to-one with dataset DOIs: a datasheet may describe a single dataset, a collection of related datasets, or a versioned release. -Before you begin, reflect on these relationships and choose a resolution that balances clarity, usability, and sustainability. -This is not a prescriptive process; it deserves deliberate consideration. - -## Before you begin - -## Datasheet Structure - The datasheet is organized into the following sections: - - Motivation - - Composition - - Collection - - Preprocessing/Cleaning/Labeling - - Uses - - Distribution - - Maintenance - - Human Subject Research (if applicable) - -## Think about your dataset's modalities -Before filling out the datasheet, consider the different data modalities present in your dataset (e.g., sensor time series, microscopy images, simulation results, logbook text). -Each modality may have unique characteristics, collection methods, and intended uses that should be documented separately. -When addressing questions in the datasheet, specify which modality you are referring to, especially if the dataset contains multiple modalities. -This will help ensure clarity and provide a comprehensive understanding of the dataset's structure and content. - -## Think about your dataset's intended uses -Before filling out the datasheet, reflect on the intended uses of your dataset. -Consider the scientific tasks it is designed to support, the research gaps it aims to address, and the theories it seeks to test. -Understanding the intended applications will help you provide more accurate and relevant information in the datasheet, particularly in sections related to motivation, uses, and limitations. -This reflection will also guide you in identifying any potential risks or ethical considerations associated with the dataset's use. - -## Consider the scope and resolution of your datasheet -Before filling out the datasheet, carefully consider the scope and resolution at which you will document your dataset. -Decide whether the datasheet will cover a single dataset, a collection of related datasets, or a versioned release. -Think about the level of detail that will be most useful for your intended audience, balancing clarity and usability with the need to avoid unnecessary duplication. -This consideration will help ensure that the datasheet effectively communicates the essential information about your dataset while remaining manageable and relevant. - -## Consider the those filling out the datasheet -Before filling out the datasheet, think about who will be completing it. -Ensure that the individuals responsible for authoring the datasheet have a comprehensive understanding of the dataset, including its creation, composition, and intended uses. -Encourage collaboration among team members who contributed to the dataset to provide accurate and detailed responses. -This collaborative approach will help ensure that the datasheet reflects a well-rounded perspective and captures all relevant information about the dataset. - -### Guidelines -1. Recommend that there be a citable ``scientific record" that contains the Datasheet and Dataset and other elements of the scientific record, including facilities, authors, publications, software, datasets, schemas, and datasheets. -2. Recommend assigning a separate DOI to the Datasheet for independent versioning and authorship tracking, apart from related manuscripts and datasets. -3. Recommend providing a version of this datasheet with information that is suitable for the widest possible distribution and citation allowed under applicable laws. -If applicable and to the extent permissible in a document for such wide distribution, the datasheet should cite one or more controlled (at various classification levels, proprietary, or export controlled) versions of itself, and individual responses should indicate that the controlled version has different information. - Such different ``versions" of the document should have their own unique persistent identifiers. -4. Recommend a broad reading of the text of the questions, and provide partial answers if some parts cannot be provided. - For example, if a grant from a private foundation lacks an associated grant identifier, the entity's name and a description of the grant should be provided. - If providing the name of the grantor or the purpose of creation is not permissible at the wide distribution level, that portion can be left unanswered with an explicit marking of ``not available." -5. item If a combination of responses is not permitted (e.g., indicating the title and the motivation, or the motivation and the funding agency simultaneously may not be permitted) at the wide distribution level, then the topics describing the dataset and its allowed uses are to be given preference over the provenance. -6. In situations where there might be some uncertainty, make sure the datasheet is reviewed by a derivative classifier. - -**[[REMOVE "Datasheets for Datasets: Contextualizing Scientific Data" section prior to sharing the Datasheet]]** - -# Datasheet for ${DATASET_NAME} - -## Dataset Metadata - -### Dataset citation: - -Include DOI or URL. Use information that matches a data catalog entry if applicable. Citations in [bibtex format](https://www.bibtex.com/g/bibtex-format/). Please include either a `doi` or `url` field in the citation. - -### Name and contact information for the datasheet author(s): - -A person or group that was primarily responsible for authoring the datasheet. Provide the Name, [ORCID](https://orcid.org/), affiliation (ROR ID) and email address of the person or group responsible for the datasheet. - -## Motivation - -### What was the primary purpose for creating this dataset (e.g., to support a specific scientific task, address a research gap, or test a theory)? -Describe the intended use and, if applicable, any assumptions made about the underlying system (e.g., proxy variables, stationarity, symmetry, theoretical models). - -### Was the dataset created for or in the context of an AI application? -Please provide a description of the AI application, including the type of model(s) that will be trained or evaluated using this dataset. - -### Who created the dataset (e.g., which team, research group), what are their institutional affiliations, and on behalf of which research entity (e.g., company, institution, organization) was the dataset created? - -Provide the [ROR ID](https://ror.org/) for research entities if available. - -### What resources were used? For example, what funding, facility time, computing resources, and datasets were used to create the dataset? Provide answers in the sections below. - -#### Facilities: - -list the facilities. Provide the facility name and [ROR ID](https://ror.org/) if available. - -#### Funding: - -If there are associated grants, please provide the name of the grantor(s) (e.g., federal agency and program office name) and [ROR ID](https://ror.org/) , and the grant name(s) and number(s) (e.g., PAMS Award number and/or lab contract number). List the solicitation numbers and links, or citations. Citations in [bibtex format](https://www.bibtex.com/g/bibtex-format/). Please include either a `doi` or `url` field in the citation. - -#### Other Supporting Entities: - -If available, provide a [ROR ID](https://ror.org/), link, or citation for other supporting entities. Citations in [bibtex format](https://www.bibtex.com/g/bibtex-format/). Please include either a `doi` or `url` field in the citation. - -### Was the dataset created as part of a larger initiative? -If so, provide the name for the initiative, e.g., SciDAC, ModelTeam, Genesis. - -### Any other comments regarding the motivation for creating the dataset? (optional) -Provide any additional information about the motivation for creating the dataset. - -## Composition - -### Identify the data modalities contained in the dataset (e.g., sensor time series, microscopy images, simulation results, logbook text). -If multiple modalities exist, list each one. - -### What do the instances within each modality represent (e.g., detector hits, environmental monitoring readings, simulation runs, documents)? Are there multiple types of instances (e.g., beam pulses and cavity field maps; users and their proposals)? -Provide a description for each modality. - -### How many instances are there in total (of each type, if appropriate)? -Provide the number of instances for each modality. - -### Does the dataset include all possible instances for each modality or a subset (not necessarily random)? -If a subset, what is the larger collection (e.g., all experiments at a user facility during a given period)? Is the subset intended to be representative (e.g., energy ranges, geographic sites)? Describe the criteria and validation methods used to verify representativeness. -If it is not representative, please describe why not (e.g., to capture diversity in experimental conditions; due to data access restrictions; because data was intentionally excluded, and why). - -### What data does each instance within the dataset consist of (for each modality, if appropriate)? "Raw" data (e.g., unprocessed text or images) or features? -In either case, please provide a description. - -### Is any information missing from individual instances? -If so, please provide a description explaining why this information is missing (e.g., because it was unavailable). This does not include intentionally removed information but may include, e.g., redacted text or missing sensor data. - -### Are relationships between individual instances made explicit (e.g., parent-child relationships in a tree structure, protein–protein interaction networks)? -If so, please describe how these relationships are made explicit. - -### Have the data been split, or are there recommended data splits (e.g., training, development/validation, testing)? -If so, please provide a description of these splits, explaining the rationale behind them. - -### Are there any errors, sources of noise, or redundancies in the dataset? -If so, please provide a description. - -### Are external resources (e.g., file stores, calibration information, software packages, code, websites, other datasets) needed to interpret or reuse these data? -If not, respond "No." -Otherwise, for each external resource, provide a description, and if applicable, its access point (e.g., DOIs, link, [bibtex format](https://www.bibtex.com/g/bibtex-format/) citation). Please include either a `doi` or `url` field in the citation. - -#### If there are external resources, for each external resource, are there any restrictions (e.g., licenses, fees)? - -### Does the dataset contain data that might be considered Controlled Unclassified Information (CUI) or confidential (e.g., Personally Identifiable Information (PII), Confidential Business Information (CBI), pre-publication data, data protected by legal privilege, or data that includes the content of individuals' non-public communications)? -Refer to [Open Digital Rights Language (ODRL) Vocabulary & Expression](https://www.w3.org/TR/odrl-vocab/) and the [Information Model](https://www.w3.org/TR/odrl-model/) for a standard vocabulary to represent statements about the usage of data. - -### Does the dataset contain classified or export-controlled information? -If so, please provide an explanation. - -### Any other comments regarding the composition of the dataset? (optional) -Provide any additional information about the composition of the dataset. - -## Collection - -### How was the data for each instance obtained or generated (e.g., raw experimental measurements from user facilities, processed/physics-ready experimental data, outputs from computational simulations, or data derived from prior datasets)? -If the data were simulated or derived, provide details on how accuracy and validity were assessed. - -### Did the dataset incorporate data obtained through unconventional means (e.g., online crowdsourcing, volunteer-based citizen science)? -If yes, explain how these contributions were managed, including documentation, acknowledgment, and quality assurance measures. - -### For each instrument or facility used to generate and collect the data, what mechanisms or procedures were used to collect the data (e.g., hardware apparatuses or sensors, manual human curation, software programs, software APIs)? -Provide a description for each instrument or facility used, mechanisms and procedures used to collect the data. Provide relevant details on how these mechanisms or procedures were validated. - -### If the dataset is a sample from a larger set, what was the sampling strategy (e.g., deterministic, probabilistic with specific sampling probabilities)? -Provide a description of the sampling strategy and any methods used to assess sample representativeness. - -### Over what timeframe was the data collected or generated? Does this timeframe align with when the underlying phenomena or events occurred (e.g., recent simulation of historical accelerator configurations)? If not, describe the timeframe of the original events or conditions represented by the data. -Provide single date, range, or approximate date; suggested format YYYY-MM-DD, and, if appropriate, describe alignment with underlying phenomena or events - -### Any other comments regarding the dataset collection process? (optional) -Provide any additional information about the collection process for the dataset. - -## Preprocessing/cleaning/labeling - -### To create the final dataset, was any preprocessing/ cleaning/ labeling of raw data done? - -Preprocessing, cleaning, and labeling can include simple pipelines (e.g., discretization or bucketing, tokenization, part-of-speech tagging, SIFT feature extraction, removal of instances, processing of missing values), or more complex workflows including noise reduction and filtering, calibration, reconstruction and event building, simulation post-processing, meta-data enrichment (e.g., adding uncertainty estimates). -If so, please provide a description of the workflow. - -### Was the "raw" data saved in addition to the preprocessed/cleaned/labeled data (e.g., to support unanticipated future uses)? -If so, please provide a [bibtex format](https://www.bibtex.com/g/bibtex-format/) citation, link, or other access point to the "raw" data. Please include either a `doi` or `url` field in the citation. - -### Is the software that was used to preprocess/ clean/ label the data available? -If so, please provide a [bibtex format](https://www.bibtex.com/g/bibtex-format/) citation, PID, link, or other access point, along with descriptions of any required packages or libraries to run the scripts. Please include either a `doi` or `url` field in the citation. - -### Any other comments regarding the preprocessing, cleaning, and labeling workflow for the dataset? (optional) -Provide any additional information about the preprocessing, cleaning, and labeling workflow for the dataset. - -## Uses - -### Have AI-Readiness assessment tools been used? -If so, name the tool, and describe the results? - -### To what extent are the data prepared for AI? -Describe any steps taken to make the data suitable for AI applications, such as formatting, normalization, or feature extraction. - -### If the data has been used for AI, provide a description. -Highlight how the data was used for AI, including links to code or DOIs illustrating its use. - -### What (other) tasks could the dataset be used for? -Provide examples of tasks that the dataset could be used for, beyond its original purpose. - -### Are there limitations or characteristics, arising from the collection, preprocessing, or labeling, that may impact future uses of the dataset (e.g., incomplete coverage of parameter space, data quality variations, restrictions due to CUI or export control)? -If so, please provide a description. Describe mitigation strategies for the risks? - -### Are there tasks for which the dataset should not be used? -Describe tasks not recommended for this dataset's use. - -### Were any reviews for safety, cybersecurity, export control, or other considerations conducted? -If so, please provide a description of these review processes, including the outcomes. - -### Are there tutorials or other supporting information to assist a user of these data? -If so, please provide a description and [bibtex format](https://www.bibtex.com/g/bibtex-format/) citation, PID, link, or other access point. Please include either a `doi` or `url` field in the citation. - -### Any other comments on the use of the dataset? (optional) -Provide any additional information about the use of the dataset. - -## Distribution - -### Will all or parts of the dataset be shared outside the originating laboratory, facility, or site (e.g., with other DOE sites, collaborating institutions, or public repositories)? -If so, please provide a description of how the dataset will be shared, including any access mechanisms (e.g., data repositories, data catalogs, or other data sharing platforms). Provide relevant links or citations in [bibtex format](https://www.bibtex.com/g/bibtex-format/). Please include either a `doi` or `url` field in the citation. - -### Indicate the date or timeframe when the dataset was (or will be) made available. -Provide a single date or range; suggested format YYYY-MM-DD. - -### Access and reuse restrictions placed on the data: -Will the dataset be shared under a copyright or other intellectual property (IP) license, and/or under applicable terms of use (ToU)? -If your dataset is split into multiple parts (e.g., training and test sets), you may need to answer separately for each part. -If so, please describe this license and/or ToU, and provide a link or other access point to, or otherwise reproduce, any relevant licensing terms or ToU, as well as any fees associated with these restrictions. - -### Have any third parties imposed IP-based or other restrictions on the data associated with the instances? -If so, please describe these restrictions and provide a link or other access point to, or otherwise reproduce, any relevant licensing terms, as well as any fees associated with these restrictions. - -### Export control restrictions: -Do any export controls or other regulatory restrictions apply to the dataset or to individual instances? -If so, please describe these restrictions and provide a link or other access point to, or otherwise reproduce, any supporting documentation. - -### If the dataset or any individual instances have a classification or restriction, what is the specific level of classification or restriction (e.g., Classified, Unclassified, CUI)? -If there is no classification or restriction, respond "N/A." - -### Any other comments regarding the distribution of the dataset? (optional) -Provide any additional information about the distribution of the dataset. - -## Maintenance - -### Who will maintain and provide access to the dataset after its creation? -For example, DOE-supported repository, facility staff, or facility data steward. -Provide responsible parties’ names, roles, contact information, and persistent identifiers (e.g., [ORCID](https://orcid.org/)). -Indicate any DOE policies or agreements governing maintenance and retention. - -### Is there an erratum? -If so, please provide a link or other access point. - -### Will the dataset be updated (e.g., to correct labeling errors, add new instances, delete instances)? -If so, please describe how often, by whom, and how updates will be communicated to dataset consumers (e.g., mailing list, GitHub)? - -### Will older versions of the dataset continue to be supported/hosted/maintained? -If so, please describe how. If not, please describe how its obsolescence will be communicated to dataset consumers. - -### What is the expected lifespan of the dataset? -Provide a description of the expected lifespan, including any plans for archiving or decommissioning, including stakeholders, communities, and collaborators who will be consulted. If a retention policy applies, please provide a link or other access point to, or otherwise reproduce, the relevant documentation. - -### Will the dataset support controlled extensions or updates from collaborators? -If yes, describe the mechanism for submission, validation, and versioning, and how these changes will be communicated to users.Otherwise, explain why contributions are restricted. - -### Any other comments regarding the maintenance of the dataset? (optional) -Provide any additional information about the maintenance of the dataset. - -## Human Subject Research - -### Does the dataset include personal information (e.g., identifiable data, survey responses)? -If not, respond "No" and ignore the remainder of the Datasheet. -Otherwise, respond "Yes" and continue. - -### Human Subject Research: Dataset Composition - -#### Does the dataset identify any subpopulations (e.g., by age, gender, career stage)? -If not, answer "No." Otherwise, please describe how these subpopulations are identified and provide a description of their respective distributions within the dataset. - -#### Is it possible to identify individuals (i.e., one or more natural persons), either directly or indirectly (i.e., in combination with other data) from the dataset? -If not, answer "No." Otherwise, please describe how. - -#### Does the dataset contain data that might be considered sensitive in any way (e.g., data that reveals race or ethnic origins, sexual orientations, religious beliefs, political opinions or union memberships, or locations; financial or health data; biometric or genetic data; forms of government identification, such as social security numbers; criminal history)? -If not, answer "No." Otherwise, please provide a description. - -### Human Subject Research: Collection - -#### Were any ethical review processes conducted (e.g., by an institutional review board)? -If so, please provide a description of these review processes, including the outcomes, and a link or other access point to any supporting documentation. - -#### Were the individuals in question notified about the data collection? -If so, please describe (or show with screenshots or other information) how notice was provided, and provide a link or other access point to, or otherwise reproduce, the exact language of the notification itself. - -#### Did the individuals in question consent to the collection and use of their data? -If so, please describe (or show with screenshots or other information) how consent was requested and provided, and provide a link or other access point to, or otherwise reproduce, the exact language to which the individuals consented. - -#### If consent was obtained, were the consenting individuals provided with a mechanism to revoke their consent in the future or for certain uses? -If so, please provide a description and a link or other access point to the mechanism (if applicable). - -#### Has an analysis of the potential impact of the dataset and its use on data subjects (e.g., a data protection impact analysis) been conducted? -If so, please provide a description of this analysis, including the outcomes, as well as a link or other access point to any supporting documentation. - -### Human Subject Research: Maintenance - -#### Are there applicable limits on the retention of the data associated with the instances (e.g., were the individuals in question told that their data would be retained for a fixed period of time and then deleted)? -If so, please describe these limits and explain how they will be enforced. - -### Human Subject Research: Other - -#### Any other comments regarding the inclusion of human subject research in the dataset? (optional) -Provide any additional information about the inclusion of human subject research in the dataset. diff --git a/src/dsagt/skills/datacard-generator/reference/field-prompts.md b/src/dsagt/skills/datacard-generator/reference/field-prompts.md deleted file mode 100644 index 5be7460..0000000 --- a/src/dsagt/skills/datacard-generator/reference/field-prompts.md +++ /dev/null @@ -1,38 +0,0 @@ -# Per-Level Field Prompts - -Ask 3–5 fields at a time. Only prompt for fields not already auto-filled. - -## Level 1 — Discoverable - -- Dataset name and title -- Project name -- Dataset identifiers (DOI, URL) -- Dataset description -- Keywords -- Release status - -## Level 2 — Interoperable & Reusable (adds to Level 1) - -- Contact point (name, email, affiliation, ORCID) -- Access policy (sensitivity tier, access level, authorization) — see lookup-tables.md for sensitivity tiers -- License (SPDX ID, name, URL) -- Science domain -- Originating research organization -- Funding sources -- Dataset authors and contributors -- Provenance — how was the data generated or collected? -- Related resources (datasets, publications, software) -- Maintenance and stewardship plans -- Key dates (collection start/end, issued, modified) - -## Level 3 — Understandable & Trustworthy (adds to Level 2) - -- Semantic/schema info — ontologies, controlled vocabularies (must be user-provided) -- Data quality — completeness, known issues, validation methods, noise, uncertainty -- Integrity — checksums, fixity policy, versioning strategy -- AI/ML usage — AI-ready status, training/inference permissions, bias risks, safety notes -- Variable-level documentation — name, description, unit, value labels (one row per variable) -- Missing data codes -- Data processing steps - -**If the user selects Level 3 but provides minimal information:** generate the card with N/A for unprovided fields, list them in the review summary, and note which ones most improve trustworthiness. \ No newline at end of file diff --git a/src/dsagt/skills/datacard-generator/reference/introspection-commands.md b/src/dsagt/skills/datacard-generator/reference/introspection-commands.md deleted file mode 100644 index 0fd9517..0000000 --- a/src/dsagt/skills/datacard-generator/reference/introspection-commands.md +++ /dev/null @@ -1,80 +0,0 @@ -# Introspection Commands - -## Contents -- File structure and size -- Schema and data sampling (CSV, Parquet, JSON, HDF5/NetCDF, images) -- Existing metadata files -- Directory structure and splits -- Edge case handling - -## File Structure and Size - -```bash -# Full recursive file listing -find -type f | sort - -# Count files by extension -find -type f | sed 's/.*\.//' | sort | uniq -c | sort -rn - -# Total size (human-readable) -du -sh - -# Size of top-level items -du -sh /* -``` - -## Schema and Data Sampling - -```bash -# CSV/TSV — headers, row count, dtypes -head -n 3 -wc -l -python3 -c "import pandas as pd; df = pd.read_csv('', nrows=5); print(df.dtypes); print(df.shape)" - -# Parquet -python3 -c "import pandas as pd; df = pd.read_parquet(''); print(df.dtypes); print(df.shape)" - -# JSON — top-level structure -python3 -c "import json; d=json.load(open('')); print(type(d), list(d.keys()) if isinstance(d,dict) else f'{len(d)} items')" - -# HDF5 -python3 -c "import h5py; f=h5py.File('','r'); print(list(f.keys())); f.visititems(lambda n,o: print(n, type(o).__name__))" - -# NetCDF -python3 -c "import netCDF4 as nc; d=nc.Dataset(''); print(d.variables.keys()); print(d.dimensions)" - -# Images — count by format -find -type f \( -iname "*.jpg" -o -iname "*.png" -o -iname "*.tif" \) | wc -l -``` - -For files larger than 1GB, sample only (e.g., `nrows=1000`) and note this in the review summary. - -## Existing Metadata Files - -```bash -# Check for common metadata files -for f in README.md README.txt LICENSE CITATION.cff CITATION.bib .zenodo.json datacite.json croissant.json metadata.json; do - [ -f "/$f" ] && echo "FOUND: $f" -done - -# Print contents of found files -cat /README.md 2>/dev/null -cat /CITATION.cff 2>/dev/null -cat /LICENSE 2>/dev/null -``` - -## Directory Structure and Splits - -```bash -# Top-level listing and directory tree (2 levels) -ls / -find -maxdepth 2 -type d | sort -``` - -Look for `train/`, `test/`, `validation/`, or file naming patterns like `_train.csv`. - -## Edge Case Handling - -- **Empty directory**: Inform the user; ask whether to proceed with a skeleton card -- **Binary-only files**: Note under "Files & Structure" and mark schema fields N/A -- **Large files (>1GB)**: Sample only; note in the review summary \ No newline at end of file diff --git a/src/dsagt/skills/datacard-generator/reference/lookup-tables.md b/src/dsagt/skills/datacard-generator/reference/lookup-tables.md deleted file mode 100644 index e549645..0000000 --- a/src/dsagt/skills/datacard-generator/reference/lookup-tables.md +++ /dev/null @@ -1,32 +0,0 @@ -# Lookup Tables - -## OSTI Dataset Type Codes - -Use when setting `dataset_info.dataset_type`: - -| Code | Type | -|------|------| -| `GD` | Genome/Genetic Data | -| `IM` | Image | -| `ND` | Numeric Data | -| `SM` | Specialized Mix | -| `FP` | Figure/Plot | -| `I` | Interactive Resource | -| `MM` | Multimedia | -| `MD` | Model | -| `AS` | Automated Software | -| `IP` | Instrumentation and Protocols | -| `IG` | Integrated Genomic Resources | - -## Sensitivity Tiers - -Use when setting `access_policy.sensitivity_tier`: - -| Tier | Label | -|------|-------| -| `tier0` | Open Science | -| `tier1` | Controlled Research | -| `tier2` | Proprietary | -| `tier3` | Sensitive / Export Controlled | -| `tier4` | Regulated / Personal | -| `tier5` | Classified | \ No newline at end of file diff --git a/src/dsagt/skills/skill-creator/SKILL.md b/src/dsagt/skills/skill-creator/SKILL.md index 2f20e0f..c4978b9 100644 --- a/src/dsagt/skills/skill-creator/SKILL.md +++ b/src/dsagt/skills/skill-creator/SKILL.md @@ -58,7 +58,7 @@ Confirm before saving: ### 6. Save -Save via the **`save_skill`** MCP tool (registry server) with the `spec` (frontmatter dict: `name`, `description`, optional `tags`), the `body` markdown, and any `reference_files` (a `{relative_path: contents}` map). This writes `/skills//` and indexes it for `search_skills`. +Save via the **`save_skill`** MCP tool with the `spec` (frontmatter dict: `name`, `description`, optional `tags`), the `body` markdown, and any `reference_files` (a `{relative_path: contents}` map). This writes `/skills//`, which the agent natively auto-discovers after the next `dsagt start` (no KB indexing — `search_skills` is for the not-yet-installed catalog). ### 7. Confirm diff --git a/tests/smoke_test/run.sh b/tests/smoke_test/run.sh index 2973567..8914eca 100755 --- a/tests/smoke_test/run.sh +++ b/tests/smoke_test/run.sh @@ -173,12 +173,12 @@ check() { check "csvtool_filter spec written" "test -f '${PDIR}/tools/csvtool_filter.md'" check "trace_archive has records" "ls '${PDIR}/trace_archive/'*.json | grep -q ." check "scan_directory record" "ls '${PDIR}/trace_archive/'*scan_directory*.json | grep -q ." -# Both files are written by dsagt-knowledge-server's kb_ingest_directory MCP -# tool — chroma.sqlite3 is the actual vector DB, route.json is the collection -# manifest. Checking only `test -d kb_index/knowledge` is too weak: an agent -# can satisfy it by hand-crafting an empty directory tree, masking a broken -# MCP wiring (which is exactly what we hit when cline's dsagt-knowledge -# server crashed silently and the LLM compensated by mkdir-ing the path). +# Both files are written by dsagt-server's kb_ingest MCP tool — chroma.sqlite3 +# is the actual vector DB, route.json is the collection manifest. Checking +# only `test -d kb_index/knowledge` is too weak: an agent can satisfy it by +# hand-crafting an empty directory tree, masking a broken MCP wiring (which is +# exactly what we hit when cline's dsagt server crashed silently and the LLM +# compensated by mkdir-ing the path). check "knowledge ingested (route)" "test -f '${PDIR}/kb_index/knowledge/route.json'" check "knowledge ingested (vectors)" "test -f '${PDIR}/kb_index/knowledge/chroma.sqlite3'" # Explicit memory writes to /explicit_memories.yaml (YAML at the @@ -218,8 +218,8 @@ for _, row in df.iterrows(): spans = row.get("spans") or [] # Match by service.name on root span — agent-emitted traces only. # MCP-server traces (kb.*, registry.*, tool.execute) carry - # service.name = "dsagt-knowledge-server" / "dsagt-registry-server" / - # "dsagt-run" and shouldn't count toward agent turn parity. + # service.name = "dsagt-server" / "dsagt-run" and shouldn't count + # toward agent turn parity. for s in spans: attrs = getattr(s, "attributes", None) or ( s.get("attributes") if isinstance(s, dict) else None diff --git a/tests/test_config.py b/tests/test_config.py index adfc439..6670b70 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -486,8 +486,8 @@ def test_claude_writes_mcp_json(self, tmp_path): mcp_path = working_dir / ".mcp.json" assert mcp_path.exists() mcp = json.loads(mcp_path.read_text()) - assert "dsagt-registry" in mcp["mcpServers"] - assert "dsagt-knowledge" in mcp["mcpServers"] + assert set(mcp["mcpServers"]) == {"dsagt"} + assert mcp["mcpServers"]["dsagt"]["args"] == ["run", "dsagt-server"] assert (working_dir / "CLAUDE.md").exists() # BYOA: .dsagt_env is no longer written; user manages shell env. assert not (working_dir / ".dsagt_env").exists() @@ -502,8 +502,8 @@ def test_goose_writes_goose_yaml(self, tmp_path): goose_path = working_dir / "goose.yaml" assert goose_path.exists() goose = yaml.safe_load(goose_path.read_text()) - assert "registry" in goose["extensions"] - assert "knowledge" in goose["extensions"] + assert set(goose["extensions"]) == {"dsagt"} + assert goose["extensions"]["dsagt"]["cmd"] == "uv run dsagt-server" assert (working_dir / ".goosehints").exists() def test_roo_writes_static_and_dynamic(self, tmp_path): @@ -520,7 +520,8 @@ def test_roo_writes_static_and_dynamic(self, tmp_path): # comes from dsagt_config.yaml via cwd-walk; the MCP env block # only carries EMBEDDING_* settings. mcp = json.loads((working_dir / ".roo" / "mcp.json").read_text()) - assert "EMBEDDING_BACKEND" in mcp["mcpServers"]["dsagt-registry"]["env"] + assert set(mcp["mcpServers"]) == {"dsagt"} + assert "EMBEDDING_BACKEND" in mcp["mcpServers"]["dsagt"]["env"] def test_cline_writes_static_only_in_split_test(self, tmp_path): # Cline's dynamic writer shells out to `cline auth` and `cline mcp @@ -547,7 +548,7 @@ def test_codex_writes_static_and_dynamic(self, tmp_path): assert (working_dir / "AGENTS.md").exists() assert (working_dir / ".codex-data").is_dir() toml = (working_dir / ".codex-data" / "config.toml").read_text() - assert "[mcp_servers.dsagt-registry.env]" in toml + assert "[mcp_servers.dsagt.env]" in toml # Project routing comes from dsagt_config.yaml via cwd-walk; the # MCP env block only carries EMBEDDING_* settings. assert "EMBEDDING_BACKEND" in toml @@ -595,9 +596,8 @@ def test_codex_config_toml_shape(self, tmp_path): } toml = _render_codex_config(mcp_env) - assert "[mcp_servers.dsagt-registry]" in toml - assert "[mcp_servers.dsagt-knowledge]" in toml - assert "[mcp_servers.dsagt-registry.env]" in toml + assert "[mcp_servers.dsagt]" in toml + assert "[mcp_servers.dsagt.env]" in toml assert 'MLFLOW_TRACKING_URI = "http://localhost:5001"' in toml # OTel opt-in: enables OTLP-HTTP export + un-redacts user prompt. assert "[otel]" in toml @@ -632,10 +632,10 @@ def test_opencode_config_json_shape(self): parsed = json.loads(body) assert parsed["$schema"] == "https://opencode.ai/config.json" - assert set(parsed["mcp"]) == {"dsagt-registry", "dsagt-knowledge"} - reg = parsed["mcp"]["dsagt-registry"] + assert set(parsed["mcp"]) == {"dsagt"} + reg = parsed["mcp"]["dsagt"] assert reg["type"] == "local" - assert reg["command"] == ["uv", "run", "dsagt-registry-server"] + assert reg["command"] == ["uv", "run", "dsagt-server"] assert reg["environment"]["DSAGT_PROJECT_DIR"] == "/proj" # Provider block uses {env:VAR} reference, never the resolved value. assert ( @@ -707,11 +707,11 @@ def test_mcp_servers_dict_shape(self): } mcp = _build_mcp_servers_dict(env_block) - assert set(mcp["mcpServers"]) == {"dsagt-registry", "dsagt-knowledge"} - assert mcp["mcpServers"]["dsagt-knowledge"]["disabled"] is False + assert set(mcp["mcpServers"]) == {"dsagt"} + assert mcp["mcpServers"]["dsagt"]["disabled"] is False # Env block plumbs through so the MCP server children have what they need. assert ( - mcp["mcpServers"]["dsagt-registry"]["env"]["MLFLOW_TRACKING_URI"] + mcp["mcpServers"]["dsagt"]["env"]["MLFLOW_TRACKING_URI"] == "http://localhost:5001" ) @@ -727,11 +727,10 @@ def test_mcp_config_omits_redundant_dsagt_env(self, tmp_path): self._write_both(config, working_dir) mcp = json.loads((working_dir / ".mcp.json").read_text()) - for server in ("dsagt-registry", "dsagt-knowledge"): - env = mcp["mcpServers"][server].get("env", {}) - assert "DSAGT_PROJECT" not in env - assert "DSAGT_PROJECT_DIR" not in env - assert "MLFLOW_TRACKING_URI" not in env + env = mcp["mcpServers"]["dsagt"].get("env", {}) + assert "DSAGT_PROJECT" not in env + assert "DSAGT_PROJECT_DIR" not in env + assert "MLFLOW_TRACKING_URI" not in env # --------------------------------------------------------------------------- @@ -1291,9 +1290,7 @@ def test_goose(self): "goose", "session", "--with-extension", - "uv run dsagt-registry-server", - "--with-extension", - "uv run dsagt-knowledge-server", + "uv run dsagt-server", ] def test_roo(self): @@ -1386,13 +1383,12 @@ def test_mcp_env_block_omits_empty_embedding_keys(self): assert "EMBEDDING_MODEL" not in env def test_mcp_server_args_are_just_command(self): - """MCP server args are just ["run", "dsagt--server"]. - All configuration flows through env vars and dsagt_config.yaml. + """MCP server args are just ["run", "dsagt-server"] — one merged + server. All configuration flows through dsagt_config.yaml (cwd-walk). """ from dsagt.agents import _mcp_server_args - assert _mcp_server_args("knowledge") == ["run", "dsagt-knowledge-server"] - assert _mcp_server_args("registry") == ["run", "dsagt-registry-server"] + assert _mcp_server_args() == ["run", "dsagt-server"] def test_mcp_env_block_carries_only_embedding_settings(self): from dsagt.agents import _mcp_env_block diff --git a/tests/test_dsagt_server.py b/tests/test_dsagt_server.py new file mode 100644 index 0000000..d537e34 --- /dev/null +++ b/tests/test_dsagt_server.py @@ -0,0 +1,81 @@ +"""Tests for the merged ``dsagt-server`` (registry + knowledge under one Server). + +These verify the *composition* contract: every tool from both concern modules is +exposed under one MCP ``Server``, and the single ``call_tool`` wrapper preserves +both return-type contracts (registry handlers return a plain string; knowledge +handlers return a dict that gets JSON-encoded). +""" + +import asyncio +import json +from pathlib import Path +from unittest.mock import MagicMock + +import mcp.types as types +import pytest + +from dsagt.commands.dsagt_server import create_dsagt_server +from dsagt.registry import SkillRegistry, ToolRegistry + + +def _make_merged_server(tmp_path: Path): + kb = MagicMock() + kb.index_dir = tmp_path / "kb_index" + kb.index_dir.mkdir() + kb.default_rerank = True + kb.collections = [] + runtime = str(tmp_path / "runtime") + reg = ToolRegistry(source_tools_dir=None, runtime_dir=runtime, kb=None) + sreg = SkillRegistry(source_skills_dir=None, runtime_dir=runtime, kb=None) + return create_dsagt_server(reg, kb, sreg, runtime_dir=runtime) + + +def _list_tools(server) -> list[str]: + handler = server.request_handlers[types.ListToolsRequest] + res = asyncio.run(handler(types.ListToolsRequest(method="tools/list"))) + return sorted(t.name for t in res.root.tools) + + +def _call(server, name: str, arguments: dict) -> str: + handler = server.request_handlers[types.CallToolRequest] + req = types.CallToolRequest( + method="tools/call", + params=types.CallToolRequestParams(name=name, arguments=arguments), + ) + res = asyncio.run(handler(req)) + return res.root.content[0].text + + +def test_merged_server_exposes_all_tools(tmp_path): + """Both concern modules' tools land under one server with no collision.""" + server = _make_merged_server(tmp_path) + names = _list_tools(server) + # 11 registry + 12 knowledge = 23 distinct tools. + assert len(names) == 23 + assert len(set(names)) == len(names) # no name collision + # Representative tools from each side. + for expected in ( + "get_registry", + "search_skills", + "kb_search", + "list_skill_sources", + ): + assert expected in names + + +def test_registry_tool_returns_plain_string(tmp_path): + """Registry handlers return a bare string — passed through unchanged.""" + server = _make_merged_server(tmp_path) + out = _call(server, "get_registry", {}) + # Not JSON — the registry contract is a human-readable string. + with pytest.raises(json.JSONDecodeError): + json.loads(out) + assert "tools:" in out + + +def test_knowledge_tool_returns_json(tmp_path): + """Knowledge handlers return a dict — JSON-encoded by the wrapper.""" + server = _make_merged_server(tmp_path) + out = _call(server, "list_skill_sources", {}) + parsed = json.loads(out) + assert "sources" in parsed diff --git a/tests/test_info.py b/tests/test_info.py index 6b1fd22..afb3f9f 100644 --- a/tests/test_info.py +++ b/tests/test_info.py @@ -150,7 +150,7 @@ def test_report_aggregates_by_source_and_session(config): "trace_metadata": _metadata( session="sess-A", agent="claude", in_t=200, out_t=0, ), - "spans": _spans_for("dsagt-knowledge-server"), + "spans": _spans_for("dsagt-server"), }, { "trace_id": "t4", @@ -175,8 +175,8 @@ def test_report_aggregates_by_source_and_session(config): assert sources["claude-code"]["input_tokens"] == 2300 assert sources["claude-code"]["output_tokens"] == 170 assert sources["claude-code"]["errors"] == 1 - assert sources["dsagt-knowledge-server"]["traces"] == 1 - assert sources["dsagt-knowledge-server"]["errors"] == 0 + assert sources["dsagt-server"]["traces"] == 1 + assert sources["dsagt-server"]["errors"] == 0 assert [s["session"] for s in r["by_session"]] == ["sess-B", "sess-A"] sess_a = next(s for s in r["by_session"] if s["session"] == "sess-A") diff --git a/tests/test_registry_server.py b/tests/test_registry_server.py index 206ff46..461187e 100644 --- a/tests/test_registry_server.py +++ b/tests/test_registry_server.py @@ -630,7 +630,7 @@ def test_search_skills_empty_catalog_hints_to_sync(self, tmp_path): server, reg, kb = _make_server_with_kb(tmp_path) text = call_tool(server, "search_skills", {"query": "vasp pymatgen dft"}) - assert "No skills found" in text + assert "No catalog skills found" in text assert "no external skill catalog is synced" in text.lower() assert "add_skill_source" in text diff --git a/tests/test_skill_discovery.py b/tests/test_skill_discovery.py new file mode 100644 index 0000000..704d973 --- /dev/null +++ b/tests/test_skill_discovery.py @@ -0,0 +1,229 @@ +"""Unit tests for the keyword scorer and SkillRouter (no network, no embedder). + +The router is exercised in its KB-free keyword mode against a real +``SkillRegistry`` (bundled skills suppressed via an empty source dir) plus a +fake catalog cache, and in KB mode against a small fake KnowledgeBase. +""" + +import json + +from dsagt import skill_keyword +from dsagt.registry import SkillRegistry +from dsagt.skill_discovery import SkillRouter + + +def _mkskill(d, name, desc): + d.mkdir(parents=True, exist_ok=True) + (d / "SKILL.md").write_text( + f"---\nname: {name}\ndescription: {desc}\n---\n# {name}\nbody\n" + ) + return d + + +def _registry(tmp_path, skills=None): + """SkillRegistry with bundled skills suppressed and optional project skills.""" + empty_bundled = tmp_path / "no_bundled" + empty_bundled.mkdir() + reg = SkillRegistry( + runtime_dir=tmp_path / "proj", + source_skills_dir=str(empty_bundled), + kb=None, + ) + for name, desc in (skills or {}).items(): + _mkskill(reg.skills_dir / name, name, desc) + return reg + + +class FakeKB: + """Minimal duck-typed KnowledgeBase: collections + search + index_dir.""" + + def __init__(self, collections, hits_by_collection=None, index_dir="/tmp/none"): + self.collections = list(collections) + self._hits = hits_by_collection or {} + self.index_dir = index_dir + + def search(self, query, collection, top_k=5): + return self._hits.get(collection, [])[:top_k] + + +def _hit(name, text, source, score, tags=""): + return { + "chunk": { + "metadata": {"skill_name": name, "source": source, "tags": tags}, + "text": text, + }, + "score": score, + } + + +# --------------------------------------------------------------------------- +# keyword scorer +# --------------------------------------------------------------------------- + + +def test_score_name_token_weighs_more_than_description(): + name_hit = skill_keyword.score_skill("slurm", "slurm-submit", "unrelated text") + desc_hit = skill_keyword.score_skill("slurm", "other", "submit a slurm job") + assert name_hit > desc_hit + + +def test_score_exact_name_beats_substring(): + exact = skill_keyword.score_skill("datacard", "datacard", "x") + substr = skill_keyword.score_skill("datacard", "datacard-generator", "x") + assert exact > substr > 0 + + +def test_score_substring_description_bonus(): + assert skill_keyword.score_skill("batch job", "x", "submit a batch job") > 0 + + +def test_stopwords_do_not_score(): + # only stopwords overlap → no score + assert skill_keyword.score_skill("the and of", "the skill", "and of the") == 0.0 + + +def test_empty_query_scores_zero(): + assert skill_keyword.score_skill("", "anything", "anything") == 0.0 + + +def test_substring_bonuses_are_mutually_exclusive(): + # Genesis parity: at most ONE of the +6/+4/+2 substring bonuses fires. + # name-token 2 + desc-token 1 + exact-name 6 = 9; the desc-substring +2 is + # NOT also added (a stacking bug would give 11). + assert skill_keyword.score_skill("alpha", "alpha", "alpha tool") == 9.0 + + +def test_single_char_tokens_dropped(): + # "x" is a single char → not a token, so no name-token overlap. + assert skill_keyword.score_skill("x", "x", "y") == 6.0 # only the exact-name bonus + + +def test_rank_orders_and_breaks_ties_by_name(): + skills = [ + {"name": "zeta", "description": "submit jobs"}, + {"name": "alpha", "description": "submit jobs"}, + {"name": "unrelated", "description": "nothing here"}, + ] + ranked = skill_keyword.rank_skills("submit", skills, top_k=5) + names = [s["name"] for s, _ in ranked] + assert names == ["alpha", "zeta"] # equal score → name asc; unrelated dropped + + +# --------------------------------------------------------------------------- +# keyword-mode search (kb is None) +# --------------------------------------------------------------------------- + + +def test_search_keyword_is_catalog_only(tmp_path): + # Installed skills are NOT search candidates (they're natively discovered); + # only the cached catalog is keyword-scored. + reg = _registry(tmp_path, {"slurm-submit": "submit a batch job to slurm"}) + cache = tmp_path / "cache" + _mkskill( + cache / "genesis" / "slurm-catalog", + "slurm-catalog", + "submit a batch job to slurm", + ) + r = SkillRouter(skill_registry=reg, cache_dir=cache) + out = r.search("slurm batch") + assert "slurm-catalog" in out # catalog skill found + assert "slurm-submit" not in out # installed skill NOT surfaced by search + assert "[catalog · install_skill to add]" in out + + +def test_search_keyword_includes_catalog_cache(tmp_path): + reg = _registry(tmp_path) + cache = tmp_path / "cache" + _mkskill( + cache / "genesis-skills" / "croissant", + "croissant-validator", + "validate a croissant metadata file", + ) + r = SkillRouter(skill_registry=reg, cache_dir=cache) + out = r.search("croissant") + assert "croissant-validator" in out + assert "[catalog · install_skill to add]" in out + + +def test_search_is_stateless(tmp_path): + # No recency queue: repeating a query yields the same result, no suppression. + reg = _registry(tmp_path) + cache = tmp_path / "cache" + _mkskill(cache / "src" / "slurm-x", "slurm-x", "submit a batch job to slurm") + r = SkillRouter(skill_registry=reg, cache_dir=cache) + first = r.search("slurm") + second = r.search("slurm") + assert first == second + assert "Found 1 skill" in second + + +def test_search_exact_name_is_kb_free(tmp_path): + reg = _registry(tmp_path, {"datacard-gen": "make a dataset card"}) + r = SkillRouter(skill_registry=reg) + out = r.search(skill_name="datacard-gen") + assert "datacard-gen" in out + assert r.search(skill_name="nope").startswith("No skill named") + + +# --------------------------------------------------------------------------- +# KB-mode search +# --------------------------------------------------------------------------- + + +def test_search_kb_merges_catalog_collections(tmp_path): + # Only skills_catalog__* collections are searched; the installed 'skills' + # collection is ignored even if present. + kb = FakeKB( + collections=["skills", "skills_catalog__a", "skills_catalog__b"], + hits_by_collection={ + "skills": [_hit("installed-one", "installed", "registered", 0.99)], + "skills_catalog__a": [_hit("cat-a", "catalog a", "catalog:a", 0.9)], + "skills_catalog__b": [_hit("cat-b", "catalog b", "catalog:b", 0.5)], + }, + ) + r = SkillRouter(skill_registry=_registry(tmp_path), kb=kb) + out = r.search("anything") + assert "installed-one" not in out # installed collection not searched + assert "cat-a" in out and "cat-b" in out + assert out.index("cat-a") < out.index("cat-b") # higher score first + + +def test_search_kb_tag_filter(tmp_path): + kb = FakeKB( + collections=["skills_catalog__x"], + hits_by_collection={ + "skills_catalog__x": [ + _hit("tagged", "x", "catalog:x", 0.9, tags="hpc,slurm"), + _hit("untagged", "y", "catalog:x", 0.8, tags=""), + ] + }, + ) + r = SkillRouter(skill_registry=_registry(tmp_path), kb=kb) + out = r.search("x", tag="slurm") + assert "tagged" in out and "untagged" not in out + + +# --------------------------------------------------------------------------- +# list_sources +# --------------------------------------------------------------------------- + + +def test_list_sources_flags_synced(tmp_path): + from dsagt.commands.skills_catalog import KNOWN_SOURCES, _repo_slug + from dsagt.registry import catalog_collection + + genesis_coll = catalog_collection(_repo_slug(KNOWN_SOURCES["genesis"]["url"])) + index_dir = tmp_path / "idx" + (index_dir / genesis_coll).mkdir(parents=True) + (index_dir / genesis_coll / "chroma_ids.json").write_text( + json.dumps(["1", "2", "3"]) + ) + + kb = FakeKB(collections=[genesis_coll], index_dir=str(index_dir)) + # list_sources needs only a KB — no skill_registry required. + r = SkillRouter(kb=kb) + sources = {s["name"]: s for s in r.list_sources()} + assert sources["genesis"]["synced"] is True + assert sources["genesis"]["indexed"] == 3 + assert sources["anthropic"]["synced"] is False + assert sources["anthropic"]["indexed"] == 0 diff --git a/tests/test_skills_catalog.py b/tests/test_skills_catalog.py index 277313a..6d5428c 100644 --- a/tests/test_skills_catalog.py +++ b/tests/test_skills_catalog.py @@ -34,12 +34,35 @@ def test_repo_slug_is_collection_safe(): assert sc._repo_slug("git@github.com:Foo/Bar.git") == "foo-bar" +def test_repo_slug_is_host_agnostic(): + # Non-GitHub hosts (GitLab, etc.) reduce to owner-repo, scheme/host dropped. + assert sc._repo_slug("https://gitlab.osti.gov/genesis/genesis-skills") == ( + "genesis-genesis-skills" + ) + assert sc._repo_slug("git@gitlab.osti.gov:genesis/genesis-skills.git") == ( + "genesis-genesis-skills" + ) + + +def test_known_source_genesis_covers_whole_skills_tree(): + spec = sc.resolve_source("genesis") + assert spec["url"] == "https://gitlab.osti.gov/genesis/genesis-skills" + # subdir scopes the recursive SKILL.md walk to the whole skills/ tree so + # every category (hpc, huggingface, langchain, …) is discoverable. + assert spec["subdir"] == "skills" + assert spec["branch"] == "main" + + def test_persist_source_to_config_appends_and_dedupes(tmp_path): import yaml cfg = tmp_path / "dsagt_config.yaml" cfg.write_text(yaml.dump({"project": "p", "skills": {"sources": []}})) - spec = {"name": "anthropic", "url": "https://github.com/anthropics/skills", "branch": "main"} + spec = { + "name": "anthropic", + "url": "https://github.com/anthropics/skills", + "branch": "main", + } assert sc.persist_source_to_config(tmp_path, spec) is True sources = yaml.safe_load(cfg.read_text())["skills"]["sources"] assert sources[-1]["name"] == "anthropic" @@ -210,3 +233,119 @@ def test_mirror_truncates_long_description(tmp_path): assert len(front["description"]) <= _NATIVE_DESCRIPTION_CAP # Source untouched. assert len((src / "SKILL.md").read_text()) > _NATIVE_DESCRIPTION_CAP + + +# --------------------------------------------------------------------------- +# AgentSetup.setup_skills — per-agent native-dir mirror +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "agent,subdir", + [ + ("claude", ".claude/skills"), + ("goose", ".agents/skills"), + ("cline", ".cline/skills"), + ("roo", ".roo/skills"), + ("codex", ".agents/skills"), + ], +) +def test_setup_skills_mirrors_into_native_dir(tmp_path, agent, subdir): + from dsagt.agents import AGENTS + + _mkskill(tmp_path / "skills" / "myskill", "myskill") # a project skill + actions = AGENTS[agent]().setup_skills(tmp_path, {}) + target = tmp_path + for part in subdir.split("/"): + target = target / part + assert (target / "myskill" / "SKILL.md").exists() + assert any("kill" in a for a in actions) # reported a mirror action + + +def test_setup_skills_respects_populate_native_false(tmp_path): + from dsagt.agents import AGENTS + + _mkskill(tmp_path / "skills" / "myskill", "myskill") + actions = AGENTS["claude"]().setup_skills( + tmp_path, {"skills": {"populate_native": False}} + ) + assert actions == [] + assert not (tmp_path / ".claude" / "skills").exists() + + +# --------------------------------------------------------------------------- +# install_into_project — license / attribution capture +# --------------------------------------------------------------------------- + + +def test_install_captures_ancestor_attribution(tmp_path): + cache = tmp_path / "cache" + repo = cache / "srcrepo" + repo.mkdir(parents=True) + (repo / "LICENSE").write_text("Apache-2.0") # repo-root license + cat = repo / "skills" / "modcon" + cat.mkdir(parents=True) + (cat / "ATTRIBUTION.md").write_text("upstream credits") # per-subtree + _mkskill(cat / "myskill", "myskill") + + proj = tmp_path / "proj" + proj.mkdir() + info = sc.install_into_project("myskill", proj, cache_dir=cache) + dest = proj / "skills" / "myskill" + assert (dest / "SKILL.md").exists() + assert (dest / "ATTRIBUTION.md").read_text() == "upstream credits" + assert (dest / "LICENSE").read_text() == "Apache-2.0" + prov = (dest / "PROVENANCE.txt").read_text() + assert "srcrepo" in prov and "skills/modcon/myskill" in prov + assert set(info["attribution"]) == {"ATTRIBUTION.md", "LICENSE"} + + +def test_install_skill_local_license_wins(tmp_path): + cache = tmp_path / "cache" + repo = cache / "srcrepo" + repo.mkdir(parents=True) + (repo / "LICENSE").write_text("ROOT") # repo-root license + skill = _mkskill(repo / "myskill", "myskill") + (skill / "LICENSE").write_text("SKILL-LOCAL") # skill bundles its own + + proj = tmp_path / "proj" + proj.mkdir() + info = sc.install_into_project("myskill", proj, cache_dir=cache) + dest = proj / "skills" / "myskill" + # The skill's own LICENSE (copied by copytree) must not be overwritten. + assert (dest / "LICENSE").read_text() == "SKILL-LOCAL" + assert "LICENSE" not in info["attribution"] + + +# --------------------------------------------------------------------------- +# index_catalog — frontmatter-only embedding (progressive disclosure) +# --------------------------------------------------------------------------- + + +def test_index_catalog_embeds_frontmatter_not_body(tmp_path): + captured = {} + + class _KB: + index_dir = tmp_path / "idx" + collections: list = [] + + def add_entries(self, texts, collection, metadatas=None): + captured["texts"] = texts + captured["metas"] = metadatas + return {} + + skill = tmp_path / "myskill" + skill.mkdir() + (skill / "SKILL.md").write_text( + "---\nname: myskill\ndescription: does a thing\ntags: [hpc, slurm]\n---\n" + "# Body\nSECRET_BODY_MARKER should not be embedded.\n" + ) + dirs = sc._discover_skill_dirs(tmp_path) + sc.index_catalog(dirs, "slug", "http://x", _KB()) + + joined = " ".join(captured["texts"]) + assert "myskill" in joined and "does a thing" in joined # frontmatter embedded + assert "hpc" in joined and "slurm" in joined # tags embedded + assert "SECRET_BODY_MARKER" not in joined # body NOT embedded + # description is also carried in metadata for the search summary. + assert captured["metas"][0]["description"] == "does a thing" From 95a49fa44284d5388058f61846a5d637c3a0db76 Mon Sep 17 00:00:00 2001 From: aarontuor Date: Tue, 23 Jun 2026 23:09:21 -0700 Subject: [PATCH 08/44] =?UTF-8?q?chore(release):=200.3.0=20=E2=80=94=20sin?= =?UTF-8?q?gle=20dsagt-server=20+=20catalog-only=20skill=20discovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump to 0.3.0 (breaking: the registry/knowledge MCP servers merge into one dsagt-server; the old console scripts are removed). CHANGELOG documents the motivation and a rebuild-not-migrate upgrade note; README version refs bumped. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 44 ++++++++++++++++++++++++++++++++++++++++++- README.md | 4 ++-- src/dsagt/__init__.py | 2 +- 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92eb9e2..e595c94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,47 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [0.3.0] - 2026-06-23 + +This release consolidates the agent-facing surface. The registry and knowledge +MCP servers were two processes that each loaded their own embedder and opened +their own ChromaDB — pure duplication, plus a write-here/read-there hazard on +the shared skill-catalog collections. They collapse into one `dsagt-server` +(one embedder, one Chroma owner, one connection per agent), so startup is faster +and there are fewer moving parts per project. In parallel, skill discovery now +leans on each agent's *native* SKILL.md auto-discovery, so `search_skills` is +reserved for the one job native discovery can't do — browsing the external +catalog of skills you haven't installed yet. + +**Upgrading (forwards compatibility).** There is no automatic migration — +adopting 0.3.0 is rebuild-not-migrate, and no project data changes: +- Re-run `dsagt start ` for each existing project; it regenerates the + per-agent MCP config to point at the single `dsagt-server`. +- For **cline** only, delete `/.cline-data` first — `cline mcp add` + has no remove, so the stale `dsagt-registry`/`dsagt-knowledge` entries would + otherwise linger next to the new one. +- Tools, skills, the KB index, traces, and memory all carry over untouched. + +### Changed +- **The two MCP servers are now one `dsagt-server`** — one shared + `KnowledgeBase`/embedder, one MCP entry per agent, one trace `service.name`. +- Skill discovery is now **catalog-only**: installed and bundled skills are + discovered natively by every supported agent, so `search_skills` covers only + the not-yet-installed external catalog. Catalogs are indexed on frontmatter + (name + description + tags) rather than the full SKILL.md body. +- `search_skills` gains a zero-dependency **keyword fallback** (a token-overlap + scorer) so it works even when no embedding model is configured. +- Installing a catalog skill now preserves upstream `LICENSE`/`NOTICE` + provenance and stamps a `PROVENANCE.txt` into the installed skill directory. + +### Removed +- **BREAKING:** the `dsagt-registry-server` and `dsagt-knowledge-server` console + scripts, replaced by `dsagt-server` (see **Upgrading** above). +- The bundled `datacard-generator` skill — it lives in the Genesis catalog and + is now installed on demand via `dsagt skills add genesis`. +- Dead indexing of installed/bundled skills into the `skills` ChromaDB + collection (nothing read it after the catalog-only search change). + ## [0.2.0] - 2026-06-23 ### Added @@ -44,6 +85,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). generation, MLflow/OTel observability, the tool/skill registry, execution provenance, and explicit + episodic memory. -[Unreleased]: https://github.com/AI-ModCon/dsagt/compare/v0.2.0...HEAD +[Unreleased]: https://github.com/AI-ModCon/dsagt/compare/v0.3.0...HEAD +[0.3.0]: https://github.com/AI-ModCon/dsagt/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/AI-ModCon/dsagt/releases/tag/v0.2.0 [0.1.0]: https://github.com/AI-ModCon/dsagt/releases/tag/v0.1.0 diff --git a/README.md b/README.md index 8674d71..9599245 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ If you just want to *run* DSAgt against your own data and agent — no repo chec python3.12 -m venv ~/.venvs/dsagt # or: conda create -n dsagt python=3.12 && conda activate dsagt source ~/.venvs/dsagt/bin/activate # (Windows venv: ~\.venvs\dsagt\Scripts\activate) pip install "git+https://github.com/AI-ModCon/dsagt.git" -dsagt --version # 0.2.0 +dsagt --version # 0.3.0 ``` This puts the `dsagt` CLI (and the `dsagt-run` / `dsagt-*-server` helpers) on your PATH. Then build the shared knowledge base once and create your first project: @@ -49,7 +49,7 @@ pip install --upgrade "git+https://github.com/AI-ModCon/dsagt.git" dsagt setup-kb ``` -> Pin to a specific release once tags are published, e.g. `pip install "git+https://github.com/AI-ModCon/dsagt.git@v0.2.0"`. +> Pin to a specific release once tags are published, e.g. `pip install "git+https://github.com/AI-ModCon/dsagt.git@v0.3.0"`. ### For development diff --git a/src/dsagt/__init__.py b/src/dsagt/__init__.py index 8337be1..02c75b9 100644 --- a/src/dsagt/__init__.py +++ b/src/dsagt/__init__.py @@ -6,7 +6,7 @@ # Single source of truth for the package version: pyproject.toml reads this # via `[tool.setuptools.dynamic] version = {attr = "dsagt.__version__"}`. -__version__ = "0.2.0" +__version__ = "0.3.0" # Cap CPU thread count for embedding / tokenization libraries before any # heavy imports happen. Without this, PyTorch / sentence-transformers / From 8030e7a00b4eb4066f8adefdf71023e18aad0d1d Mon Sep 17 00:00:00 2001 From: aarontuor Date: Tue, 23 Jun 2026 23:17:19 -0700 Subject: [PATCH 09/44] docs: single dsagt-server sweep + skill-routing figure in Tools & Skills - Add the skill-routing diagram (assets/skills-routing.png) to tools-skills.md with a "Skill discovery architecture" section motivating the two-tier (catalog vs native) split, catalog-only search, the keyword fallback, the single SkillRouter entry point, and per-source federation/provenance. - Sweep README + docs for the old two-server model: mcp-servers, architecture, cli, developer, quickstart, knowledge-base now describe one dsagt-server. - Fix stale skill-indexing language: bundled/installed skills are auto- discovered natively (not indexed); setup-kb rebuilds Tool Specs only; the KB collection tables list "Skills Catalog" (external, per-source) instead of a bundled "Skills" collection. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 16 ++++++++-------- docs/architecture.md | 12 ++++++------ docs/assets/skills-routing.png | Bin 0 -> 89524 bytes docs/cli.md | 3 +-- docs/developer.md | 5 ++--- docs/knowledge-base.md | 6 +++--- docs/mcp-servers.md | 16 +++++++--------- docs/quickstart.md | 8 ++++---- docs/tools-skills.md | 24 +++++++++++++++++++++--- 9 files changed, 52 insertions(+), 38 deletions(-) create mode 100644 docs/assets/skills-routing.png diff --git a/README.md b/README.md index 9599245..1c97314 100644 --- a/README.md +++ b/README.md @@ -107,8 +107,8 @@ What this exercised: | Prompt | Layer | |---|---| -| 1 | Knowledge MCP server (`kb_ingest`) — chunks and indexes docs into ChromaDB | -| 2 | Registry MCP server (`save_tool_spec`) — writes `tools/csvcut.md`, `tools/csvgrep.md`, etc. (one per registered tool) | +| 1 | `dsagt-server` (`kb_ingest`) — chunks and indexes docs into ChromaDB | +| 2 | `dsagt-server` (`save_tool_spec`) — writes `tools/csvcut.md`, `tools/csvgrep.md`, etc. (one per registered tool) | | 3 | `dsagt-run` provenance wrapper — records exec layer to `trace_archive/` | | 4 | Explicit memory (`kb_remember` → `explicit_memories.yaml`) + KB recall | @@ -127,10 +127,10 @@ The same flow runs non-interactively via `dsagt smoke-test --agent claude` (or ` `dsagt setup-kb` builds the shared ChromaDB collections under `~/.dsagt/kb_index/` that every project on this machine reuses. Three of the six collections shown in the [architecture diagram](#architecture) are populated here — the other three are per-project and fill in automatically during use (see [Knowledge Base](#knowledge-base) below): - **Tool Specs** — DSAgt's bundled tool specs from `src/dsagt/tools/`, tagged with `source: bundled` so the agent finds them via `search_registry` from the very first session. -- **Skills** — DSAgt's bundled skill workflows from `src/dsagt/skills/` (e.g. `skill-creator`), discovered via `search_skills`. Domain skills (datacards, etc.) come from external catalogs — `dsagt skills add genesis`. +- **Skills Catalog** — the default external skill source (`scientific`) cloned and frontmatter-indexed so `search_skills` returns installable skills from the first session. Add more (`dsagt skills add genesis`, etc.). The bundled `skill-creator` is auto-discovered natively by the agent, not indexed. - **Domain Knowledge** — Reference corpora (NVIDIA NeMo Curator, AI Data Readiness Inspector) downloaded and embedded so the agent has data-curation domain knowledge out of the box. -The Tool Specs and Skills collections are wipe-and-rebuild on every run, so re-run `setup-kb` after upgrading DSAgt to pick up new bundled assets. +The Tool Specs collection is wipe-and-rebuild on every run, so re-run `setup-kb` after upgrading DSAgt to pick up new bundled tools. (Bundled skills are not indexed — agents auto-discover them natively.) ```bash dsagt setup-kb # all collections (local embedder, no creds) @@ -192,11 +192,11 @@ DSAGT exposes a single MCP server, **`dsagt-server`**, that an agent connects to ### Tools and Skills -**Tools** are CLI executables defined as markdown files with YAML frontmatter in `/tools/`. The agent registers new tools via the registry MCP server's `save_tool_spec`. +**Tools** are CLI executables defined as markdown files with YAML frontmatter in `/tools/`. The agent registers new tools via the MCP server's `save_tool_spec`. **Skills** are instruction-based agent workflows — a directory with a `SKILL.md` and optional reference docs. They come in two tiers: -- **Installed** skills live in `/skills/` (DSAgt ships a bundled `skill-creator`; domain skills like the MODCON datacard generator are installed from the `genesis` catalog). These are mirrored into the agent's native skill directory (e.g. `.claude/skills/`, `.agents/skills/`) at `dsagt init`/`start` so the agent auto-invokes them, and they are also searchable via `search_skills`. +- **Installed** skills live in `/skills/` (DSAgt ships a bundled `skill-creator`; domain skills like the MODCON datacard generator are installed from the `genesis` catalog). These are mirrored into the agent's native skill directory (e.g. `.claude/skills/`, `.agents/skills/`) at `dsagt init`/`start`, where the agent auto-discovers and auto-invokes them — no `search_skills` needed (that covers only the catalog tier below). - **Catalog** skills come from external Git repositories — GitHub *or* GitLab — indexed into a searchable catalog the agent browses with `search_skills` but that is **not** loaded into its context (so a catalog can hold thousands of skills). The agent enables a source with `add_skill_source(...)`, finds skills with `search_skills(...)`, then copies one into the project with `install_skill(...)`. The catalog is **opt-in**: a source must be synced before its skills are searchable. Curated named sources ship out of the box — `scientific`, `anthropic`, `antigravity`, `composio`, and `genesis` (the OSTI GENESIS catalog: HPC, HuggingFace, LangChain, OpenAI, plasma-sim, and more) — and any Git URL or `owner/repo` works too. Manage catalogs from the CLI with `dsagt skills list/search/add/sync `, or from the agent with `list_skill_sources` / `add_skill_source` / `search_skills` / `install_skill`. @@ -212,7 +212,7 @@ Six independently-partitioned ChromaDB collections hold everything the agent sea | Collection | Source | Populated by | |---|---|---| | **Tool Specs** | Bundled CLI tool specs in `src/dsagt/tools/` | `dsagt setup-kb` | -| **Skills** | Bundled skill workflows in `src/dsagt/skills/` | `dsagt setup-kb` | +| **Skills Catalog** | Installable skills from external repos (one `skills_catalog__` per source), frontmatter-indexed | `dsagt setup-kb` (default source) + `add_skill_source` | | **Domain Knowledge** | NeMo Curator + AIDRIN reference corpora; user-ingested docs | `dsagt setup-kb` + agent's `kb_ingest` | | **Explicit Memory** | User-confirmed facts | Agent's `kb_remember` (also written to `/explicit_memories.yaml`); the agent fetches via `kb_get_memories` on demand — typically when you ask it to recall — not auto-loaded at session start | | **Episodic Memory** | Distilled facts from MLflow traces | `dsagt memory --project ` (per-category outlier detection via embedding centroids) | @@ -220,7 +220,7 @@ Six independently-partitioned ChromaDB collections hold everything the agent sea The default embedding backend is local (sentence-transformers, CPU-side, no API needed). Switch to `embedding.backend: api` in `dsagt_config.yaml` to route through a hosted embedder via LiteLLM. Cross-encoder reranking is optional (`knowledge.rerank: true`). -The agent searches via `kb_search` (knowledge MCP server) and writes via `kb_ingest` / `kb_remember`. Tool Specs and Skills are queried through specialized routes (`search_registry`, `search_skills`) over the same backend. Enabling external skill catalogs adds one `skills_catalog__` collection per source, which `search_skills` queries alongside the bundled Skills collection. +The agent searches via `kb_search` and writes via `kb_ingest` / `kb_remember`. Registered tools have their own `search_registry` route over the same backend. Installed skills are auto-discovered natively by the agent (not indexed); enabling external skill catalogs adds one `skills_catalog__` collection per source, which `search_skills` browses for installable skills. ### Observability diff --git a/docs/architecture.md b/docs/architecture.md index 5724f82..e3d00ba 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,18 +2,18 @@ ![DSAgt architecture](assets/architecture.png) -DSAgt wraps an unmodified agent CLI with four independently-operable layers. Each layer exposes its own MCP server so the agent discovers and invokes capabilities through the standard MCP tool protocol. +DSAgt wraps an unmodified agent CLI with four independently-operable layers. The tool-registry and knowledge-base layers are exposed through one MCP server (`dsagt-server`); the agent discovers and invokes their capabilities through the standard MCP tool protocol. ## Layers -**Tool Registry** (`dsagt-registry-server`) -The agent registers CLI tools as markdown files with YAML frontmatter under `/tools/`. The registry server handles dependency installation via `uv run --with` and wraps every execution with `dsagt-run` for provenance capture. The agent discovers tools via `search_registry`. +**Tool Registry** (`dsagt-server`) +The agent registers CLI tools as markdown files with YAML frontmatter under `/tools/`. DSAgt handles dependency installation via `uv run --with` and wraps every execution with `dsagt-run` for provenance capture. The agent discovers tools via `search_registry`. -**Knowledge Base** (`dsagt-knowledge-server`) -Semantic search over six independently-partitioned ChromaDB collections. Three are global (populated by `dsagt setup-kb`); three are per-project (filled automatically during use). Background jobs handle long ingest operations. The agent searches via `kb_search`, ingests via `kb_ingest`, and saves user-confirmed facts via `kb_remember`. +**Knowledge Base** (`dsagt-server`) +Semantic search over six independently-partitioned ChromaDB collections, served by the same process as the tool registry (one shared embedder, one ChromaDB owner). Three collections are global (populated by `dsagt setup-kb`); three are per-project (filled automatically during use). Background jobs handle long ingest operations. The agent searches via `kb_search`, ingests via `kb_ingest`, and saves user-confirmed facts via `kb_remember`. **Provenance** (`dsagt-run`) -A thin wrapper invoked by the registry server around every tool execution. Records the command, arguments, exit code, duration, file counts, and truncated stderr to `/trace_archive/.json` and emits an OTLP span to MLflow. The agent calls `reconstruct_pipeline` to render the trace archive as a reproducible bash script or Snakemake workflow. +A thin wrapper around every registered-tool execution. Records the command, arguments, exit code, duration, file counts, and truncated stderr to `/trace_archive/.json` and emits an OTLP span to MLflow. The agent calls `reconstruct_pipeline` to render the trace archive as a reproducible bash script or Snakemake workflow. **Observability** (MLflow + OTLP) MLflow runs locally at a port pinned at `dsagt init` time. All four layers emit OTLP HTTP spans to MLflow's `/v1/traces` endpoint. The agent's own LLM-call traces land in the same store when you export the `OTEL_EXPORTER_OTLP_ENDPOINT` printed by `dsagt init`. diff --git a/docs/assets/skills-routing.png b/docs/assets/skills-routing.png new file mode 100644 index 0000000000000000000000000000000000000000..15c486d469fcf7e8890d94a5d1afb4306646700e GIT binary patch literal 89524 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'u5t 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 collection per source), frontmatter-indexed | `dsagt setup-kb` (default source) + `add_skill_source` | | **Domain Knowledge** | NeMo Curator + AIDRIN reference corpora; user-ingested docs | `dsagt setup-kb` + agent's `kb_ingest` | | **Explicit Memory** | User-confirmed facts | Agent's `kb_remember` (also written to `/explicit_memories.yaml`) | | **Episodic Memory** | Distilled facts from MLflow traces | `dsagt memory --project ` | @@ -23,7 +23,7 @@ Explicit memories are facts the user confirms during a session. The agent saves ## Search -The agent searches all collections via `kb_search` (knowledge MCP server) and writes via `kb_ingest` / `kb_remember`. Tool Specs and Skills are queried through specialized routes (`search_registry`, `search_skills`) over the same backend. +The agent searches all collections via `kb_search` and writes via `kb_ingest` / `kb_remember`. Registered tools have their own `search_registry` route over the same backend. Skills are discovered separately — installed ones natively by the agent, installable ones via `search_skills` over the external catalog (see [Tools & Skills](tools-skills.md)). Hybrid search (dense embeddings + sparse BM25 via Reciprocal Rank Fusion) is on by default per collection route. Cross-encoder reranking is optional. @@ -37,4 +37,4 @@ dsagt setup-kb --embedding-backend api \ --embedding-api-key ``` -The Tool Specs and Skills collections are wiped and rebuilt on every `setup-kb` run — re-run after upgrading DSAgt to pick up new bundled assets. +The Tool Specs collection is wiped and rebuilt on every `setup-kb` run — re-run after upgrading DSAgt to pick up new bundled tools. (Bundled skills are not indexed — agents auto-discover them natively.) diff --git a/docs/mcp-servers.md b/docs/mcp-servers.md index ee999cc..2aa1fcd 100644 --- a/docs/mcp-servers.md +++ b/docs/mcp-servers.md @@ -1,12 +1,12 @@ -# MCP Servers +# MCP Server -DSAgt exposes its capabilities through two MCP servers. Both are launched automatically by `dsagt init` and configured in the per-agent runtime file (`.mcp.json` for Claude Code, `goose.yaml` for Goose, etc.). +DSAgt exposes its capabilities through a single MCP server, **`dsagt-server`**, configured in the per-agent runtime file (`.mcp.json` for Claude Code, `goose.yaml` for Goose, etc.) and launched automatically when the agent starts. It bundles two concern areas — a tool registry and a knowledge base — behind one process with one shared embedder and one ChromaDB owner. -## Registry Server +> Earlier versions ran two separate servers (`dsagt-registry-server` + `dsagt-knowledge-server`), merged in 0.3.0. Re-run `dsagt start ` on an existing project to regenerate its config against the single server (for cline, delete `/.cline-data` first). -**Command:** `dsagt-registry-server` +## Registry tools -Handles tool registration, dependency installation, and tool discovery. +Tool registration, dependency installation, and tool discovery. | Tool | Description | |------|-------------| @@ -17,9 +17,7 @@ Handles tool registration, dependency installation, and tool discovery. Tools are markdown files with YAML frontmatter under `/tools/`. Executables are wrapped with `dsagt-run` for provenance and `uv run --with` for Python dependencies. -## Knowledge Server - -**Command:** `dsagt-knowledge-server` +## Knowledge tools Semantic search and ingestion over indexed document collections. @@ -29,7 +27,7 @@ Semantic search and ingestion over indexed document collections. | `kb_ingest` | Index a file or directory into a named collection (runs in background for large corpora) | | `kb_remember` | Save a user-confirmed fact to explicit memory | | `kb_get_memories` | Retrieve explicit memories for the current project | -| `search_skills` | Discover agent skill workflows | +| `search_skills` | Discover installable skills in the external catalog (installed skills are auto-discovered natively) | ### Backend diff --git a/docs/quickstart.md b/docs/quickstart.md index 32d6702..07e09cb 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -48,8 +48,8 @@ dsagt stop quickstart | Prompt | DSAgt layer | |--------|-------------| -| 1 | Knowledge MCP server (`kb_ingest`) — chunks and indexes docs into ChromaDB | -| 2 | Registry MCP server (`save_tool_spec`) — writes `tools/csvcut.md`, etc. | +| 1 | `dsagt-server` (`kb_ingest`) — chunks and indexes docs into ChromaDB | +| 2 | `dsagt-server` (`save_tool_spec`) — writes `tools/csvcut.md`, etc. | | 3 | `dsagt-run` provenance wrapper — records exec layer to `trace_archive/` | | 4 | KB recall via `kb_search` and registered tool execution | | 5–6 | Explicit memory (`kb_remember` → `explicit_memories.yaml`) + `kb_get_memories` | @@ -85,9 +85,9 @@ dsagt setup-kb --embedding-backend api --embedding-base-url ... --embedding-api- Three collections are populated: - **Tool Specs** — DSAgt's bundled tool specs from `src/dsagt/tools/`, tagged `source: bundled`. -- **Skills** — DSAgt's bundled skill workflows from `src/dsagt/skills/`. +- **Skills Catalog** — the default external skill source (`scientific`), cloned and frontmatter-indexed so `search_skills` has installable skills out of the box. - **Domain Knowledge** — NeMo Curator and AI Data Readiness Inspector reference corpora. -The Tool Specs and Skills collections are wiped and rebuilt on every run, so re-run `setup-kb` after upgrading DSAgt. +The Tool Specs collection is wiped and rebuilt on every run, so re-run `setup-kb` after upgrading DSAgt. (Bundled skills are not indexed — agents auto-discover them natively.) The default embedder is a local sentence-transformers model (~130 MB, CPU-only, no API key). Pass `--embedding-backend api` to route through a hosted embedder via LiteLLM. diff --git a/docs/tools-skills.md b/docs/tools-skills.md index 94ab3e6..8e218a0 100644 --- a/docs/tools-skills.md +++ b/docs/tools-skills.md @@ -2,7 +2,7 @@ ## Tools -Tools are CLI executables defined as markdown files with YAML frontmatter under `/tools/`. The agent registers new tools via the registry MCP server's `save_tool_spec` tool. +Tools are CLI executables defined as markdown files with YAML frontmatter under `/tools/`. The agent registers new tools via the MCP server's `save_tool_spec` tool. A tool spec includes: @@ -24,7 +24,7 @@ Prints descriptive statistics for all columns in a CSV file. Usage: csvstat [options] [FILE] ``` -The registry server wraps every registered tool with `dsagt-run` for provenance capture and `uv run --with` for Python dependencies, so the agent can call any tool without managing environments manually. +DSAgt wraps every registered tool with `dsagt-run` for provenance capture and `uv run --with` for Python dependencies, so the agent can call any tool without managing environments manually. ### Bundled Tools @@ -32,7 +32,25 @@ DSAgt ships a `scan_directory` tool in `src/dsagt/tools/` that is indexed into t ## Skills -Skills are instruction-based agent workflows in `/skills/`. Each skill is a directory containing a `SKILL.md` file and optional reference documents. The agent discovers skills via `search_skills`. +Skills are instruction-based agent workflows in `/skills/`. Each skill is a directory containing a `SKILL.md` file and optional reference documents. + +### Skill discovery architecture + +![DSAgt skill routing](assets/skills-routing.png) + +Skills live in **two tiers**, and a single MCP service — the **SkillRouter** — is the one entry point that routes every skill operation between them: + +- **Catalog tier** — skills that exist in external repositories but are *not yet installed*. DSAgt federates many sources (`scientific`, `anthropic`, `antigravity`, `composio`, `genesis`, or any git URL); each is sparse-cloned and indexed into its own per-source ChromaDB collection. The agent browses this tier with `search_skills` and manages sources with `add_skill_source` / `list_skill_sources`. +- **Installed + created tier** — skills drawn into the project's Skill Directory (`/skills/`), either installed from the catalog (`install_skill`) or authored in place (e.g. with the bundled `skill-creator`). At `dsagt start` these are mirrored into each agent's *native* skill directory (`.claude/`, `.agents/`, `.cline/`, `.roo/`), where the agent auto-discovers and auto-invokes them. + +The diagram's three bands trace a skill's lifecycle: **Discovery** (the router) → **Registration** (the searchable catalog) → **Progressive Exposure** (the native Skill Directory the agent loads on its own). + +#### Why this shape + +- **Search is catalog-only.** Every supported agent (Claude, Codex, Goose, Cline, Roo) natively auto-discovers `SKILL.md` folders, so installed skills never need to be indexed or returned by a tool — the harness already loads them. That frees `search_skills` to do the one thing native discovery cannot: browse a catalog of potentially thousands of *un*installed skills without holding them all in context. Catalogs are indexed on frontmatter (name + description + tags), which keeps those summaries compact and avoids diluting the embedding with full SKILL.md bodies. +- **Keyword fallback, no embedder required.** When no embedding model is configured, the router falls back to a zero-dependency token-overlap scorer over the local clone cache, so `search_skills` still works (just less fuzzy) — no model download, no API key. +- **One router, not scattered policy.** Backend selection (semantic vs. keyword), the catalog/installed split, and source bookkeeping all live in the SkillRouter rather than being re-implemented at each MCP and CLI call site, so the behavior can't drift between them. +- **Federated and provenance-preserving.** Each source is an independent per-source collection, so re-syncing one never disturbs another; installing a catalog skill preserves its upstream `LICENSE`/`NOTICE` and stamps a `PROVENANCE.txt` into the installed directory. ### Bundled Skills From 433bab6dde41cef95b77b54d5828cd00c3c10b41 Mon Sep 17 00:00:00 2001 From: aarontuor Date: Tue, 23 Jun 2026 23:32:13 -0700 Subject: [PATCH 10/44] fix(cli): implement `dsagt --version` (single-sourced from __version__) The README/docs documented `dsagt --version` but the CLI had no such flag (argparse errored). Wire it from dsagt.__version__ so it reports the release the docs claim. Found while vetting the isaac_skills_demo walkthrough. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/dsagt/commands/cli.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/dsagt/commands/cli.py b/src/dsagt/commands/cli.py index 0b5ab6c..e3b62e6 100644 --- a/src/dsagt/commands/cli.py +++ b/src/dsagt/commands/cli.py @@ -1013,10 +1013,15 @@ def _run_one(agent: str) -> tuple[str, int, float]: def main(argv=None): + from dsagt import __version__ + parser = argparse.ArgumentParser( prog="dsagt", description="DSAgt project and session management." ) parser.add_argument("--verbose", action="store_true") + parser.add_argument( + "--version", action="version", version=f"dsagt {__version__}" + ) sub = parser.add_subparsers(dest="command") From 7fccb46d28cba33ffeafa56adc145608df6dddbc Mon Sep 17 00:00:00 2001 From: aarontuor Date: Tue, 23 Jun 2026 23:40:41 -0700 Subject: [PATCH 11/44] feat(skills): source-qualified catalog install to disambiguate cross-source name clashes When the same skill name exists in more than one synced source (the clone cache is machine-global, so this happens even across projects), the install guard now resolves a source-qualified '/' name instead of dead- ending. find_catalog_skill parses the '/' prefix and scopes the search; install_skill (MCP) and 'dsagt skills add' inherit it. The CLI 'add' routes a '/' target to install (not a clone) and now prints a clean error instead of a traceback on an ambiguous bare name. The ambiguity message points at the qualified form, which actually works now. Found while vetting the isaac_skills_demo walkthrough. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/dsagt/commands/cli.py | 21 +++++++++++++---- src/dsagt/commands/skills_catalog.py | 30 +++++++++++++++++------- tests/test_skills_catalog.py | 34 +++++++++++++++++++++++++++- 3 files changed, 71 insertions(+), 14 deletions(-) diff --git a/src/dsagt/commands/cli.py b/src/dsagt/commands/cli.py index e3b62e6..23deb73 100644 --- a/src/dsagt/commands/cli.py +++ b/src/dsagt/commands/cli.py @@ -433,8 +433,17 @@ def _open_kb(): return 0 if action == "add": + from dsagt.commands.skills_catalog import SKILL_SOURCES_DIR + target = args.target - is_source = ( + # A "/" target is a source-qualified *install*, not + # a new "owner/repo" source to clone — both have one '/', so distinguish + # by whether the prefix is an already-synced source's cache dir. + qualified_install = ( + target.count("/") == 1 + and (SKILL_SOURCES_DIR / target.split("/", 1)[0]).is_dir() + ) + is_source = not qualified_install and ( target in KNOWN_SOURCES or target.startswith(("http://", "https://", "git@")) or target.count("/") == 1 @@ -457,7 +466,11 @@ def _open_kb(): f"'dsagt skills add {args.project} ' to install one." ) else: - info = install_into_project(target, pdir) + try: + info = install_into_project(target, pdir) + except LookupError as e: + print(f"Error: {e}", file=sys.stderr) + return 1 print( f"{info['action'].capitalize()} skill '{info['name']}' at {info['dest_dir']}." ) @@ -1019,9 +1032,7 @@ def main(argv=None): prog="dsagt", description="DSAgt project and session management." ) parser.add_argument("--verbose", action="store_true") - parser.add_argument( - "--version", action="version", version=f"dsagt {__version__}" - ) + parser.add_argument("--version", action="version", version=f"dsagt {__version__}") sub = parser.add_subparsers(dest="command") diff --git a/src/dsagt/commands/skills_catalog.py b/src/dsagt/commands/skills_catalog.py index 3f8e7ef..d2b1d32 100644 --- a/src/dsagt/commands/skills_catalog.py +++ b/src/dsagt/commands/skills_catalog.py @@ -289,29 +289,43 @@ def index_catalog(skill_dirs: list[Path], slug: str, url: str, kb) -> int: def find_catalog_skill(name: str, *, cache_dir: Path = SKILL_SOURCES_DIR) -> Path: """Locate a cached catalog skill dir by name across all synced sources. - Matches on frontmatter ``name`` first, then directory name. Raises on - no match, or on an ambiguous match spanning more than one source repo. + Matches on frontmatter ``name`` first, then directory name. A bare name + must be unique across the machine-global clone cache; when the same name + exists in more than one synced source, pass a **source-qualified** + ``/`` (the slug is the per-source cache dir / catalog-collection + suffix, as shown by ``list_skill_sources`` / ``dsagt skills list + --catalog``) to pick one. Raises on no match or on a still-ambiguous bare + name. """ + source_filter: str | None = None + skill = name + if "/" in name: + # Skill names never contain '/', so a slash means "/". + source_filter, skill = name.split("/", 1) + matches: list[Path] = [] if cache_dir.exists(): for slug_dir in sorted(p for p in cache_dir.iterdir() if p.is_dir()): + if source_filter is not None and slug_dir.name != source_filter: + continue for d in _discover_skill_dirs(slug_dir): spec = _parse_frontmatter(d / "SKILL.md") - if spec.get("name") == name or d.name == name: + if spec.get("name") == skill or d.name == skill: matches.append(d) if not matches: + where = f" in source '{source_filter}'" if source_filter else "" raise LookupError( - f"No catalog skill named '{name}'. Run 'dsagt skills sync' or " - f"add_skill_source first, then search_skills to find one." + f"No catalog skill named '{skill}'{where}. Run 'dsagt skills sync' " + f"or add_skill_source first, then search_skills to find one." ) # Collapse matches that point at the same source repo (slug = first path # part under cache_dir); ambiguity only matters across different sources. by_source = {p.relative_to(cache_dir).parts[0]: p for p in matches} if len(by_source) > 1: + sources = sorted(by_source) raise LookupError( - f"Skill '{name}' exists in multiple sources " - f"({', '.join(sorted(by_source))}); install by source with " - f"'dsagt skills add /{name}'." + f"Skill '{skill}' exists in multiple sources ({', '.join(sources)}); " + f"reinstall with a source-qualified name, e.g. '{sources[0]}/{skill}'." ) return next(iter(by_source.values())) diff --git a/tests/test_skills_catalog.py b/tests/test_skills_catalog.py index 6d5428c..883c337 100644 --- a/tests/test_skills_catalog.py +++ b/tests/test_skills_catalog.py @@ -116,10 +116,42 @@ def test_find_catalog_skill_and_ambiguity(tmp_path): sc.find_catalog_skill("missing", cache_dir=cache) # Same skill name in a second source → ambiguous. _mkskill(cache / "srcB" / "skills" / "alpha", "alpha") - with pytest.raises(LookupError): + with pytest.raises(LookupError, match="multiple sources"): sc.find_catalog_skill("alpha", cache_dir=cache) +def test_find_catalog_skill_source_qualified(tmp_path): + cache = tmp_path / "cache" + _mkskill(cache / "srcA" / "skills" / "alpha", "alpha") + _mkskill(cache / "srcB" / "skills" / "alpha", "alpha") + + # A "/" qualifier disambiguates which source to install from. + a = sc.find_catalog_skill("srcA/alpha", cache_dir=cache) + b = sc.find_catalog_skill("srcB/alpha", cache_dir=cache) + assert a.relative_to(cache).parts[0] == "srcA" + assert b.relative_to(cache).parts[0] == "srcB" + assert a.name == b.name == "alpha" + + # Qualifying with a source that lacks the skill is a clear, source-scoped miss. + with pytest.raises(LookupError, match="in source 'srcA'"): + sc.find_catalog_skill("srcA/missing", cache_dir=cache) + + +def test_install_into_project_source_qualified(tmp_path): + cache = tmp_path / "cache" + _mkskill(cache / "srcA" / "skills" / "dup", "dup", desc="from A") + _mkskill(cache / "srcB" / "skills" / "dup", "dup", desc="from B") + proj = tmp_path / "proj" + proj.mkdir() + + # Bare ambiguous name refuses; the source-qualified form installs srcB's copy. + with pytest.raises(LookupError, match="multiple sources"): + sc.install_into_project("dup", proj, cache_dir=cache) + info = sc.install_into_project("srcB/dup", proj, cache_dir=cache) + assert info["name"] == "dup" + assert (proj / "skills" / "dup" / "SKILL.md").read_text().count("from B") == 1 + + def test_install_into_project_copies_subdirs(tmp_path): cache = tmp_path / "cache" skill = _mkskill(cache / "src" / "vasp-to-isaac", "vasp-to-isaac") From d7fda8777077d63d751e9e62aa4b35355ac671bf Mon Sep 17 00:00:00 2001 From: aarontuor Date: Tue, 23 Jun 2026 23:41:12 -0700 Subject: [PATCH 12/44] docs(changelog): note source-qualified install + dsagt --version under 0.3.0 Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e595c94..7e15b46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,12 @@ adopting 0.3.0 is rebuild-not-migrate, and no project data changes: otherwise linger next to the new one. - Tools, skills, the KB index, traces, and memory all carry over untouched. +### Added +- Source-qualified catalog install: when the same skill name exists in more + than one synced source, install a specific one with a `/` + name (via `install_skill` or `dsagt skills add /`) + instead of dead-ending on the ambiguity guard. + ### Changed - **The two MCP servers are now one `dsagt-server`** — one shared `KnowledgeBase`/embedder, one MCP entry per agent, one trace `service.name`. @@ -47,6 +53,10 @@ adopting 0.3.0 is rebuild-not-migrate, and no project data changes: - Dead indexing of installed/bundled skills into the `skills` ChromaDB collection (nothing read it after the catalog-only search change). +### Fixed +- `dsagt --version` now works (it was documented but unimplemented — argparse + errored). Reports the version from `dsagt.__version__`. + ## [0.2.0] - 2026-06-23 ### Added From c5c1bd2ca39889086e7c9a61831202cd4cfd725b Mon Sep 17 00:00:00 2001 From: aarontuor Date: Wed, 24 Jun 2026 09:01:44 -0700 Subject: [PATCH 13/44] docs: mention skills discovery/creation in the README tagline Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1c97314..2646289 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ![DSAgt architecture](latex/architecture.png) -DSAgt connects an MCP-compatible AI coding agent to tool registration, a semantic knowledge base, execution provenance, and observability infrastructure. DSAgt provides data-pipeline scaffolding around a user's existing agent CLI or VS Code extension (Claude Code, Goose, Codex, …); +DSAgt connects an MCP-compatible AI coding agent to tool registration, a semantic knowledge base, skills discovery and creation, execution provenance, and observability infrastructure. DSAgt provides data-pipeline scaffolding around a user's existing agent CLI or VS Code extension (Claude Code, Goose, Codex, …); **Prerequisites:** Python 3.12 or 3.13, and one of the supported agent platforms below — already installed and authenticated against whatever LLM provider you intend to use. ([uv](https://github.com/astral-sh/uv) is only needed for the development install.) From acb4dde700aa66b6acd9baf6c1c5121332bf66f7 Mon Sep 17 00:00:00 2001 From: aarontuor Date: Wed, 24 Jun 2026 13:30:16 -0700 Subject: [PATCH 14/44] refactor(mcp,skills): split the MCP server into a concern-based mcp/ package; consolidate skill modules into skills.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single dsagt-server now lives in src/dsagt/mcp/, composed from per-concern tool modules behind one dispatch shell: - mcp/server.py — composition, main(), shared build_dispatch_server + KB startup - mcp/registry_tools.py / knowledge_tools.py / memory_tools.py / skill_tools.py This replaces the registry_server / knowledge_server / dsagt_server trio and moves importable logic out of commands/ (which is for entry points). Entry point repointed to dsagt.mcp.server:main; agents are unchanged (same `dsagt-server`). Skill discovery is consolidated into one module src/dsagt/skills.py — the catalog data plane (SkillsCatalog), the SkillRouter render facade, and the Genesis-derived keyword scorer (was skill_keyword + skill_discovery + commands/skills_catalog). install_skill now returns a terse confirmation (the install→use→restart model already lives in the agent instructions). Remove dead src/session.py. Migrate tests to the new layout (new test_memory_tools / test_skill_tools; server-startup reworked to fast, network-free entry-point checks; memory + kb_search tests rehomed). Update CLAUDE.md and docs/mcp-servers.md to the merged-server / four-concern shape. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 21 +- docs/mcp-servers.md | 4 +- pyproject.toml | 2 +- src/dsagt/agents/base.py | 11 +- src/dsagt/commands/cli.py | 10 +- src/dsagt/commands/setup_core_kb.py | 2 +- src/dsagt/mcp/__init__.py | 12 + .../knowledge_tools.py} | 441 ++--------------- src/dsagt/mcp/memory_tools.py | 231 +++++++++ .../registry_tools.py} | 332 ++----------- .../dsagt_server.py => mcp/server.py} | 149 ++++-- src/dsagt/mcp/skill_tools.py | 386 +++++++++++++++ src/dsagt/observability.py | 6 +- src/dsagt/skill_discovery.py | 110 ----- src/dsagt/skill_keyword.py | 101 ---- .../{commands/skills_catalog.py => skills.py} | 299 +++++++++++- src/session.py | 458 ------------------ tests/mcp_helpers.py | 10 +- tests/test_dependency_integration.py | 2 +- tests/test_dsagt_server.py | 51 +- tests/test_kb_search_filters.py | 2 +- tests/test_knowledge_integration.py | 2 +- tests/test_knowledge_server.py | 156 ++++-- tests/test_knowledge_server_memory.py | 278 ----------- tests/test_memory_tools.py | 188 +++++++ tests/test_observability.py | 6 +- tests/test_registry_server.py | 143 +----- tests/test_server_startup.py | 185 ++----- tests/test_skill_discovery.py | 25 +- tests/test_skill_tools.py | 200 ++++++++ tests/test_skills_catalog.py | 2 +- 31 files changed, 1750 insertions(+), 2075 deletions(-) create mode 100644 src/dsagt/mcp/__init__.py rename src/dsagt/{commands/knowledge_server.py => mcp/knowledge_tools.py} (61%) create mode 100644 src/dsagt/mcp/memory_tools.py rename src/dsagt/{commands/registry_server.py => mcp/registry_tools.py} (59%) rename src/dsagt/{commands/dsagt_server.py => mcp/server.py} (64%) create mode 100644 src/dsagt/mcp/skill_tools.py delete mode 100644 src/dsagt/skill_discovery.py delete mode 100644 src/dsagt/skill_keyword.py rename src/dsagt/{commands/skills_catalog.py => skills.py} (63%) delete mode 100755 src/session.py delete mode 100644 tests/test_knowledge_server_memory.py create mode 100644 tests/test_memory_tools.py create mode 100644 tests/test_skill_tools.py diff --git a/CLAUDE.md b/CLAUDE.md index 9b4d4b1..8fbfcb9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,11 +32,11 @@ The codebase separates **commands** (entry points with argparse, launched as CLI - `cli.py` — `dsagt init / mlflow / memory / info / list / mv / rm / setup-kb / smoke-test / stop / start` (user-facing CLI). `dsagt start --enable-proxy` activates proxy mode; without `--enable-proxy` it's the supervised BYOA equivalent (start MLflow + agent under one process tree). - `proxy_server.py` — `dsagt-proxy` (LiteLLM proxy with OTel autolog). Spawned by `dsagt start --enable-proxy`. - `run_tool.py` — `dsagt-run` (tool execution wrapper). -- `registry_server.py` — `dsagt-registry-server` (MCP server). -- `knowledge_server.py` — `dsagt-knowledge-server` (MCP server). - `setup_core_kb.py` — core KB setup (called via `dsagt setup-kb`). - `info.py` — `dsagt info` (project / config introspection). +(The MCP server — `dsagt-server` — lives in the `src/dsagt/mcp/` package, see below.) + **Modules** (`src/dsagt/`): - `session.py` — Project init, agent config generation, env-var resolution, config load/validate, service start/stop, end-of-session memory extraction orchestration. - `agents/` — Per-agent-platform setup (`base.py` ABC + `claude.py` / `goose.py` / `cline.py` / `roo.py` / `codex.py`). Each subclass owns its `write_static`, `write_dynamic`, `env_overrides`, `byoa_env_hints`, `launch_oneliner`. Shared helpers (`_mcp_env_block`, `_render_launch_shim`, `_build_mcp_servers_dict`) in `base.py`. @@ -45,8 +45,11 @@ The codebase separates **commands** (entry points with argparse, launched as CLI - `provenance.py` — Tool execution records (`run_and_record`, `ToolRecordStore`), execution-record indexing into ChromaDB, pipeline reconstruction (`reconstruct_pipeline`, dependency graph). - `observability.py` — MLflow / OTel tracing setup, `init_tracing`, span helpers. - `memory.py` — Explicit memory (YAML), episodic-memory extraction prompt + LLM call, outlier detection, `extract_session`. +- `skills.py` — External skill catalog data plane (`SkillsCatalog`: clone/sync/index/install), the `SkillRouter` render facade, and the Genesis-derived keyword scorer (`rank_skills`). + +**MCP server** (`src/dsagt/mcp/`) — the single merged `dsagt-server`. `server.py` owns `main()`, the shared-KB startup (`_build_kb_from_config`), and the dispatch shell (`build_dispatch_server`); the tool surface is split by concern across `registry_tools.py` (tool registry + execution + provenance, 8 tools), `knowledge_tools.py` (KB retrieval, 6), `memory_tools.py` (explicit memory + suggestions, 4), and `skill_tools.py` (skill search/install/sources, 5). Each `*_tools.py` exposes a `_*_tools_and_handlers()` factory (composed by `create_dsagt_server`) plus a `create_*_server` test wrapper. -Entry points are defined in `pyproject.toml` `[project.scripts]` and all point to `dsagt.commands.*:main`. +Entry points are defined in `pyproject.toml` `[project.scripts]`: the CLI/proxy/run/setup-kb tools point to `dsagt.commands.*:main`, and `dsagt-server` points to `dsagt.mcp.server:main`. **Bundled assets** (shipped as `package-data` in `pyproject.toml`): - `src/dsagt/tools/` — built-in tool specs (markdown + YAML frontmatter) copied into new projects. @@ -67,16 +70,20 @@ Entry points are defined in `pyproject.toml` `[project.scripts]` and all point t ## Architecture -### MCP Servers +### MCP Server + +A single merged `dsagt-server` (`src/dsagt/mcp/`) exposes 23 tools across four concern modules under one `Server` + one shared `KnowledgeBase`: -1. **Registry Server** (`commands/registry_server.py` + `registry.py`) — Tool analysis, registration, dependency installation. Tools are saved as skill files (markdown with YAML frontmatter). -2. **Knowledge Server** (`commands/knowledge_server.py` + `knowledge.py`) — Semantic search over document collections using FAISS + ChromaDB with optional cross-encoder reranking. Background jobs for long operations. +1. **Registry tools** (`mcp/registry_tools.py` + `registry.py` / `provenance.py`) — tool analysis, registration, dependency installation, command/file/http execution, and pipeline reconstruction. Tools are saved as markdown specs with YAML frontmatter. +2. **Knowledge tools** (`mcp/knowledge_tools.py` + `knowledge.py`) — semantic search over document collections (FAISS + ChromaDB, optional cross-encoder reranking); long ops run as background jobs. +3. **Memory tools** (`mcp/memory_tools.py` + `memory.py`) — explicit memory + outlier suggestions (`kb_remember` / `kb_get_memories` / …). +4. **Skill tools** (`mcp/skill_tools.py` + `skills.py`) — skill search / install + external catalog sources. ### Observability - **MLflow** — Token usage, cost, latency, full LLM-call traces via OTel. Started by `dsagt mlflow ` (foreground, in its own terminal). Port is pinned at init time and lives in `dsagt_config.yaml`. - **dsagt-run** (`commands/run_tool.py` + `provenance.py`) — Wraps tool commands; captures execution layer (command, stdout/stderr, timing, file lists) into `trace_archive/`. -- **MCP-server OTel** — Both servers call `init_tracing()` at startup; their tool spans (kb.*, registry.*) flow to MLflow alongside the agent's LLM-call spans. +- **MCP-server OTel** — `dsagt-server` calls `init_tracing()` at startup; its tool spans (kb.*, registry.*) flow to MLflow alongside the agent's LLM-call spans. ### Memory System diff --git a/docs/mcp-servers.md b/docs/mcp-servers.md index 2aa1fcd..4dc922f 100644 --- a/docs/mcp-servers.md +++ b/docs/mcp-servers.md @@ -1,8 +1,8 @@ # MCP Server -DSAgt exposes its capabilities through a single MCP server, **`dsagt-server`**, configured in the per-agent runtime file (`.mcp.json` for Claude Code, `goose.yaml` for Goose, etc.) and launched automatically when the agent starts. It bundles two concern areas — a tool registry and a knowledge base — behind one process with one shared embedder and one ChromaDB owner. +DSAgt exposes its capabilities through a single MCP server, **`dsagt-server`**, configured in the per-agent runtime file (`.mcp.json` for Claude Code, `goose.yaml` for Goose, etc.) and launched automatically when the agent starts. It bundles four concern areas — a tool registry, a knowledge base, explicit memory, and skill discovery — behind one process with one shared embedder and one ChromaDB owner. -> Earlier versions ran two separate servers (`dsagt-registry-server` + `dsagt-knowledge-server`), merged in 0.3.0. Re-run `dsagt start ` on an existing project to regenerate its config against the single server (for cline, delete `/.cline-data` first). +> Earlier versions ran two separate servers (`dsagt-registry-server` + `dsagt-knowledge-server`), merged in 0.2.0. Re-run `dsagt start ` on an existing project to regenerate its config against the single server (for cline, delete `/.cline-data` first). ## Registry tools diff --git a/pyproject.toml b/pyproject.toml index 5a19a37..bde3b3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,7 +60,7 @@ dependencies = [ dsagt = "dsagt.commands.cli:main" dsagt-run = "dsagt.commands.run_tool:main" dsagt-proxy = "dsagt.commands.proxy_server:main" -dsagt-server = "dsagt.commands.dsagt_server:main" +dsagt-server = "dsagt.mcp.server:main" dsagt-setup-kb = "dsagt.commands.setup_core_kb:main" [dependency-groups] diff --git a/src/dsagt/agents/base.py b/src/dsagt/agents/base.py index b1df791..928e114 100644 --- a/src/dsagt/agents/base.py +++ b/src/dsagt/agents/base.py @@ -32,13 +32,12 @@ # files between init and start without losing edits on the next start. _DSAGT_MARKER = "DSAgt Pipeline Builder" -# Tools each dsagt MCP server exposes — listed in ``alwaysAllow`` so roo +# Tools the dsagt MCP server exposes — listed in ``alwaysAllow`` so roo # and cline auto-approve them without a human-in-the-loop prompt. Keep in -# sync with ``commands/registry_server.py`` and -# ``commands/knowledge_server.py`` tool registrations; a tool added there -# but not here means roo/cline will hang on its first call. -# All dsagt MCP tools (registry + knowledge) now live behind the single -# ``dsagt-server``, so the always-allow list is one flat union. +# sync with the ``mcp/*_tools.py`` tool registrations (registry / knowledge / +# memory / skill); a tool added there but not here means roo/cline will hang on +# its first call. All dsagt MCP tools live behind the single ``dsagt-server``, +# so the always-allow list is one flat union. _DSAGT_MCP_ALWAYS_ALLOW = [ "add_skill_source", "get_registry", diff --git a/src/dsagt/commands/cli.py b/src/dsagt/commands/cli.py index 23deb73..adbe375 100644 --- a/src/dsagt/commands/cli.py +++ b/src/dsagt/commands/cli.py @@ -384,16 +384,16 @@ def _cmd_setup_kb(args): def _cmd_skills(args): """Manage external skill catalogs and project skill installs.""" - from dsagt.commands.skills_catalog import ( + from dsagt.registry import SkillRegistry + from dsagt.session import kb_from_config, load_config + from dsagt.skills import ( KNOWN_SOURCES, + SkillRouter, install_into_project, persist_source_to_config, resolve_source, sync_source, ) - from dsagt.registry import SkillRegistry - from dsagt.session import kb_from_config, load_config - from dsagt.skill_discovery import SkillRouter def _open_kb(): """Best-effort KB; None when no embedder is configured (keyword fallback).""" @@ -433,7 +433,7 @@ def _open_kb(): return 0 if action == "add": - from dsagt.commands.skills_catalog import SKILL_SOURCES_DIR + from dsagt.skills import SKILL_SOURCES_DIR target = args.target # A "/" target is a source-qualified *install*, not diff --git a/src/dsagt/commands/setup_core_kb.py b/src/dsagt/commands/setup_core_kb.py index ce81737..f1f2bb4 100644 --- a/src/dsagt/commands/setup_core_kb.py +++ b/src/dsagt/commands/setup_core_kb.py @@ -477,7 +477,7 @@ def run_setup_kb(args): # Best-effort — a clone failure (offline, repo moved) warns and # continues rather than aborting the whole KB build. if not getattr(args, "no_skill_catalog", False): - from dsagt.commands.skills_catalog import sync_source + from dsagt.skills import sync_source from dsagt.session import DEFAULTS for src in DEFAULTS["skills"]["sources"]: diff --git a/src/dsagt/mcp/__init__.py b/src/dsagt/mcp/__init__.py new file mode 100644 index 0000000..8fcb345 --- /dev/null +++ b/src/dsagt/mcp/__init__.py @@ -0,0 +1,12 @@ +"""DSAGT MCP server package — the single merged ``dsagt-server``. + +The MCP tool surface is split by concern across sibling modules: + +* :mod:`dsagt.mcp.registry_tools` — tool registry + execution + provenance +* :mod:`dsagt.mcp.knowledge_tools` — knowledge-base retrieval +* :mod:`dsagt.mcp.memory_tools` — explicit memory + suggestions +* :mod:`dsagt.mcp.skill_tools` — skill search / install / sources + +:mod:`dsagt.mcp.server` composes all four under one ``Server("dsagt")`` and +owns the ``dsagt-server`` entry point + shared-KB startup. +""" diff --git a/src/dsagt/commands/knowledge_server.py b/src/dsagt/mcp/knowledge_tools.py similarity index 61% rename from src/dsagt/commands/knowledge_server.py rename to src/dsagt/mcp/knowledge_tools.py index bf7a8c9..2ab2adc 100644 --- a/src/dsagt/commands/knowledge_server.py +++ b/src/dsagt/mcp/knowledge_tools.py @@ -1,83 +1,51 @@ +"""MCP tools for knowledge-base retrieval. + +Semantic search over document collections, background ingest/append jobs, and +registration of external vector stores. Long-running operations (ingest, +append) run in the background and return immediately with a ``job_id``; poll +``kb_job_status`` for completion. + +Server configuration (chunk_size, vector_db, rerank) is read from the project's +dsagt_config.yaml. Embedding credentials flow through env vars (LLM_API_KEY, +OPENAI_BASE_URL, EMBEDDING_MODEL) set by ``dsagt start``. + +These definitions + handlers run inside the merged ``dsagt-server`` (see +:mod:`dsagt.mcp.server`); ``create_knowledge_server`` is retained only as a +test-facing constructor. Explicit-memory tools (``kb_remember`` / etc.) live in +:mod:`dsagt.mcp.memory_tools`; skill-source tools in +:mod:`dsagt.mcp.skill_tools`. """ -DSAgt Knowledge Base MCP Server. -Provides semantic search over document collections for MCP-compatible agents. - -At startup, symlinks base indexes into a session-specific runtime directory. -All modifications (ingestion, append) happen in the runtime copy. - -Long-running operations (ingest, append) run in the background and return -immediately with a job_id. Use kb_job_status to poll for completion. - -Server configuration (chunk_size, vector_db, rerank) is read from the -project's dsagt_config.yaml. Embedding credentials flow through env vars -(LLM_API_KEY, OPENAI_BASE_URL, EMBEDDING_MODEL) set by dsagt start. - -These tool definitions + handlers run inside the merged ``dsagt-server`` -(see ``dsagt.commands.dsagt_server``). There is no standalone knowledge-server -entry point; ``create_knowledge_server`` is retained only as a test-facing -constructor. -""" - -import asyncio -import json -import logging import os -import time -from dataclasses import dataclass, field -from functools import partial # Prevent fatal OpenMP crash when multiple libraries (FAISS, PyTorch/ -# sentence-transformers) each bundle their own libomp. +# sentence-transformers) each bundle their own libomp. Must precede the +# ``dsagt.knowledge`` import below. os.environ.setdefault("KMP_DUPLICATE_LIB_OK", "TRUE") -import uuid -from pathlib import Path +import asyncio # noqa: E402 +import logging # noqa: E402 +import time # noqa: E402 +import uuid # noqa: E402 +from dataclasses import dataclass, field # noqa: E402 +from functools import partial # noqa: E402 +from pathlib import Path # noqa: E402 -import mcp.server.stdio -import mcp.types as types -from mcp.server.lowlevel import Server, NotificationOptions -from mcp.server.models import InitializationOptions +import mcp.types as types # noqa: E402 -from dsagt.knowledge import ( +from dsagt.knowledge import ( # noqa: E402 EMBEDDER_REGISTRY, VECTORINDEX_REGISTRY, CollectionRoute, KnowledgeBase, ) -from dsagt.memory import SuggestionQueue -from dsagt.memory import ExplicitMemory -from dsagt.session import _collection_exists -from dsagt.session import setup_runtime_kb # noqa: F401 (re-exported for tests) +from dsagt.mcp.server import build_dispatch_server # noqa: E402 +from dsagt.session import _collection_exists # noqa: E402 +from dsagt.session import setup_runtime_kb # noqa: E402, F401 (re-exported for tests) logger = logging.getLogger(__name__) -# --------------------------------------------------------------------------- -# MCP server helpers -# --------------------------------------------------------------------------- - - -async def _run_stdio(server: Server, name: str) -> None: - async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - InitializationOptions( - server_name=name, - server_version="0.1.0", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), - ), - ) - - -# _collection_exists is used below; setup_runtime_kb is re-exported for tests + -# the merged dsagt_server. Both live in dsagt.session (imported above). - - def _register_external_collection( kb: KnowledgeBase, collection_name: str, @@ -182,8 +150,8 @@ async def _run(): # Per-tool handlers (module-level, explicit dependencies) # # Each handler takes ``arguments: dict`` plus its dependencies as keyword -# args bound via functools.partial in create_knowledge_server(). Handlers -# return a result dict; the outer call_tool wrapper JSON-serializes it. +# args bound via functools.partial. Handlers return a result dict; the outer +# dispatch wrapper JSON-serializes it. # --------------------------------------------------------------------------- @@ -475,253 +443,30 @@ async def _handle_kb_job_status(arguments: dict, *, job_tracker: _JobTracker) -> return result -async def _handle_kb_remember( - arguments: dict, - *, - kb: KnowledgeBase, - memory: ExplicitMemory, - suggestions: SuggestionQueue, -) -> dict: - text = arguments["text"] - category = arguments.get("category", "") - session_id = arguments.get("session_id", "") - supersedes = arguments.get("supersedes") - promoted_from = arguments.get("promoted_from") - - store_result = await asyncio.to_thread( - memory.remember, - text=text, - category=category, - session_id=session_id, - supersedes=supersedes, - ) - - if not store_result.get("stored"): - return { - "status": "error", - "error": store_result.get("error", "Failed to store memory"), - } - - await asyncio.to_thread( - kb.add_entries, - texts=[text], - collection="session_memory", - metadatas=[ - { - "source_type": "explicit_memory", - "category": category, - "session_id": session_id, - } - ], - ) - - if promoted_from: - suggestions.dismiss(promoted_from) - - return { - "status": "ok", - "entry_id": store_result["entry_id"], - "superseded_id": store_result.get("superseded_id"), - "promoted_from": promoted_from, - "total_memories": await asyncio.to_thread(memory.count), - } - - -async def _handle_kb_get_memories( - arguments: dict, - *, - memory: ExplicitMemory, - suggestions: SuggestionQueue, -) -> dict: - entries = await asyncio.to_thread(memory.get_all) - pending = suggestions.get_all() - result = {"status": "ok", "count": len(entries), "memories": entries} - if pending: - result["suggestions"] = pending - result["suggestion_count"] = len(pending) - return result - - -async def _handle_kb_get_suggestions( - arguments: dict, - *, - suggestions: SuggestionQueue, -) -> dict: - pending = suggestions.get_all() - return {"status": "ok", "count": len(pending), "suggestions": pending} - - -async def _handle_kb_dismiss_suggestion( - arguments: dict, - *, - suggestions: SuggestionQueue, -) -> dict: - suggestion_id = arguments["suggestion_id"] - dismissed = suggestions.dismiss(suggestion_id) - if not dismissed: - return {"status": "error", "error": f"Suggestion not found: {suggestion_id}"} - return {"status": "ok", "dismissed": suggestion_id, "remaining": suggestions.count} - - # --------------------------------------------------------------------------- -# Server factory (thin wiring — used by main() and tests) +# Tool defs + handler map (used by the merged server and the test wrapper) # --------------------------------------------------------------------------- -async def _handle_add_skill_source( - arguments: dict, - *, - kb: KnowledgeBase, - runtime_dir: Path, -) -> dict: - """Enable a skill source (known name or GitHub URL): clone + index the catalog.""" - from dsagt.commands.skills_catalog import ( - KNOWN_SOURCES, - persist_source_to_config, - resolve_source, - ) - from dsagt.skill_discovery import SkillRouter - - source = arguments.get("source") - if not source: - return { - "error": "add_skill_source requires 'source' (known name or GitHub URL)." - } - try: - spec = resolve_source(source) - if isinstance(source, str) and source in KNOWN_SOURCES: - spec.setdefault("name", source) - router = SkillRouter(kb=kb) - stats = await asyncio.to_thread(router.sync, source) - except (ValueError, RuntimeError) as e: - return {"error": str(e)} - persist_source_to_config( - runtime_dir, {"name": spec.get("name", stats["slug"]), **spec} - ) - return { - "source": spec["url"], - "slug": stats["slug"], - "skills_indexed": stats["indexed"], - "note": "Searchable via search_skills; install one with install_skill.", - } - - -async def _handle_list_skill_sources(arguments: dict, *, kb: KnowledgeBase) -> dict: - """List known skill sources, each flagged synced/available with its count. +def _knowledge_tools_and_handlers(kb: KnowledgeBase): + """Build the knowledge-base ``(tool defs, handler map)``. - A source is ``synced`` (searchable via ``search_skills``) only after an - ``add_skill_source`` call has cloned + indexed it; otherwise it is - ``available`` (known name + URL, nothing indexed yet). Reporting the - flag + ``indexed`` count inline means the agent doesn't have to cross- - reference a separate ``synced_collections`` list to tell the difference. + Combined with the other concern modules' tools under one MCP ``Server`` by + :func:`dsagt.mcp.server.create_dsagt_server`. The rerank default is on + ``kb.default_rerank`` (set from ``knowledge.rerank`` in dsagt_config.yaml). """ - from dsagt.commands.skills_catalog import KNOWN_SOURCES, _repo_slug - from dsagt.registry import CATALOG_COLLECTION_PREFIX, catalog_collection - from dsagt.skill_discovery import SkillRouter - - synced = {c for c in kb.collections if c.startswith(CATALOG_COLLECTION_PREFIX)} - - # Single source of truth for the per-source synced/indexed view (shared - # with the CLI `skills list --catalog`). - sources = { - s["name"]: { - "url": s["url"], - "description": s["description"], - "synced": s["synced"], - "indexed": s["indexed"], - } - for s in SkillRouter(kb=kb).list_sources() - } - - # Surface any synced catalog whose source isn't in KNOWN_SOURCES (added - # by raw GitHub URL) so the count is never silently dropped. - known_colls = { - catalog_collection(_repo_slug(s["url"])) for s in KNOWN_SOURCES.values() - } - extra = sorted(synced - known_colls) - - any_synced = any(v["synced"] for v in sources.values()) or bool(extra) - return { - "sources": sources, - "other_synced_collections": extra, - "note": ( - "add_skill_source to sync a source whose synced=false; " - "then search_skills to browse. search_skills only sees synced sources." - if any_synced - else "No catalog synced yet — add_skill_source " - "(e.g. 'scientific') to enable one, then search_skills to browse." - ), - } - - -def _knowledge_tools_and_handlers( - kb: KnowledgeBase, - runtime_dir: str | Path | None = None, -): - """Build the knowledge server's ``(tool defs, handler map)``. - - Split out from :func:`create_knowledge_server` so the merged - ``dsagt-server`` can combine these with the registry server's tools - under one MCP ``Server`` without re-declaring them. - - The rerank default is on ``kb.default_rerank`` (set from - ``knowledge.rerank`` in dsagt_config.yaml). - """ - mem_dir = Path(runtime_dir) if runtime_dir else kb.index_dir.parent - memory = ExplicitMemory(runtime_dir=mem_dir) - suggestions = SuggestionQueue(mem_dir / "suggestions.json") job_tracker = _JobTracker() handlers = { - "add_skill_source": partial( - _handle_add_skill_source, kb=kb, runtime_dir=mem_dir - ), - "list_skill_sources": partial(_handle_list_skill_sources, kb=kb), "kb_list_collections": partial(_handle_kb_list_collections, kb=kb), "kb_search": partial(_handle_kb_search, kb=kb), "kb_ingest": partial(_handle_kb_ingest, kb=kb, job_tracker=job_tracker), "kb_append": partial(_handle_kb_append, kb=kb, job_tracker=job_tracker), "kb_add_vector_db": partial(_handle_kb_add_vector_db, kb=kb), "kb_job_status": partial(_handle_kb_job_status, job_tracker=job_tracker), - "kb_remember": partial( - _handle_kb_remember, kb=kb, memory=memory, suggestions=suggestions - ), - "kb_get_memories": partial( - _handle_kb_get_memories, memory=memory, suggestions=suggestions - ), - "kb_get_suggestions": partial( - _handle_kb_get_suggestions, suggestions=suggestions - ), - "kb_dismiss_suggestion": partial( - _handle_kb_dismiss_suggestion, suggestions=suggestions - ), } tools = [ - types.Tool( - name="add_skill_source", - description=( - "Enable an external agent-skill source (a known name like " - "'scientific'/'anthropic'/'antigravity'/'composio', or a GitHub URL). " - "Clones it and indexes its skills into the searchable catalog " - "(search_skills). Does NOT load them into context." - ), - inputSchema={ - "type": "object", - "properties": { - "source": { - "type": "string", - "description": "Known source name or GitHub repo URL / owner/repo", - }, - }, - "required": ["source"], - }, - ), - types.Tool( - name="list_skill_sources", - description="List known + synced external skill sources and their indexed catalogs.", - inputSchema={"type": "object", "properties": {}}, - ), types.Tool( name="kb_list_collections", description=( @@ -912,114 +657,16 @@ def _knowledge_tools_and_handlers( "required": ["job_id"], }, ), - types.Tool( - name="kb_remember", - description=( - "Store a user-confirmed fact as an explicit memory. " - "These persist across sessions. Use 'supersedes' to replace an outdated memory." - ), - inputSchema={ - "type": "object", - "properties": { - "text": { - "type": "string", - "description": "The fact to remember", - }, - "category": { - "type": "string", - "description": "Classification tag", - }, - "session_id": { - "type": "string", - "description": "Current session identifier", - }, - "supersedes": { - "type": "string", - "description": "entry_id of an existing memory this replaces", - }, - "promoted_from": { - "type": "string", - "description": "suggestion_id if promoted from outlier suggestion", - }, - }, - "required": ["text"], - }, - ), - types.Tool( - name="kb_get_memories", - description=( - "Get all active explicit memories for this project. " - "Call at session start to load project context." - ), - inputSchema={"type": "object", "properties": {}}, - ), - types.Tool( - name="kb_get_suggestions", - description=( - "Get pending memory suggestions flagged by outlier detection. " - "Present to user for confirmation or dismissal." - ), - inputSchema={"type": "object", "properties": {}}, - ), - types.Tool( - name="kb_dismiss_suggestion", - description="Dismiss a pending memory suggestion.", - inputSchema={ - "type": "object", - "properties": { - "suggestion_id": { - "type": "string", - "description": "ID of the suggestion to dismiss", - }, - }, - "required": ["suggestion_id"], - }, - ), ] return tools, handlers -def create_knowledge_server( - kb: KnowledgeBase, - runtime_dir: str | Path | None = None, -): - """Create and configure the standalone MCP knowledge server. +def create_knowledge_server(kb: KnowledgeBase): + """Create a standalone MCP server exposing only the knowledge-base tools. - Test-facing API: tests call it with a mock KB and get back a server they - can drive via call_tool_sync(). The merged ``dsagt-server`` uses + Test-facing API: tests call it with a mock KB and drive the server via + ``call_tool_sync()``. The merged ``dsagt-server`` uses :func:`_knowledge_tools_and_handlers` directly instead of this wrapper. """ - server = Server("knowledge") - tools, handlers = _knowledge_tools_and_handlers(kb, runtime_dir) - - @server.list_tools() - async def list_tools() -> list[types.Tool]: - return tools - - @server.call_tool() - async def call_tool(name: str, arguments: dict) -> list[types.TextContent]: - handler = handlers[name] - try: - result = await handler(arguments) - except ValueError as e: - result = {"status": "error", "error": str(e)} - except Exception as e: - logger.exception("Unexpected error in tool '%s'", name) - result = {"status": "error", "error": f"Unexpected error: {e}"} - return [ - types.TextContent(type="text", text=json.dumps(result, ensure_ascii=False)) - ] - - return server - - -# --------------------------------------------------------------------------- -# Entry point -# --------------------------------------------------------------------------- -# -# There is no standalone ``dsagt-knowledge-server`` entry point any more. The -# knowledge tools run inside the merged ``dsagt-server`` (see -# ``dsagt.commands.dsagt_server``), which composes -# :func:`_knowledge_tools_and_handlers` with the registry server's tools under -# one MCP ``Server`` + one shared ``KnowledgeBase``. ``create_knowledge_server`` -# above is retained as a standalone, test-facing constructor. + tools, handlers = _knowledge_tools_and_handlers(kb) + return build_dispatch_server("knowledge", tools, handlers) diff --git a/src/dsagt/mcp/memory_tools.py b/src/dsagt/mcp/memory_tools.py new file mode 100644 index 0000000..e0a2c86 --- /dev/null +++ b/src/dsagt/mcp/memory_tools.py @@ -0,0 +1,231 @@ +"""MCP tools for explicit memory + outlier suggestions. + +User-confirmed facts that persist across sessions (``kb_remember`` / +``kb_get_memories``) and the outlier-detection suggestion queue +(``kb_get_suggestions`` / ``kb_dismiss_suggestion``). These front +:mod:`dsagt.memory` (``ExplicitMemory`` + ``SuggestionQueue``); the ``kb_`` +tool-name prefix is historical (the tools were born in the knowledge server) +and is kept for agent-facing backward compatibility. + +These definitions + handlers run inside the merged ``dsagt-server`` (see +:mod:`dsagt.mcp.server`); ``create_memory_server`` is retained only as a +test-facing constructor. +""" + +import asyncio +import logging +from functools import partial +from pathlib import Path + +import mcp.types as types + +from dsagt.knowledge import KnowledgeBase +from dsagt.mcp.server import build_dispatch_server +from dsagt.memory import ExplicitMemory, SuggestionQueue + +logger = logging.getLogger(__name__) + + +async def _handle_kb_remember( + arguments: dict, + *, + kb: KnowledgeBase, + memory: ExplicitMemory, + suggestions: SuggestionQueue, +) -> dict: + text = arguments["text"] + category = arguments.get("category", "") + session_id = arguments.get("session_id", "") + supersedes = arguments.get("supersedes") + promoted_from = arguments.get("promoted_from") + + store_result = await asyncio.to_thread( + memory.remember, + text=text, + category=category, + session_id=session_id, + supersedes=supersedes, + ) + + if not store_result.get("stored"): + return { + "status": "error", + "error": store_result.get("error", "Failed to store memory"), + } + + await asyncio.to_thread( + kb.add_entries, + texts=[text], + collection="session_memory", + metadatas=[ + { + "source_type": "explicit_memory", + "category": category, + "session_id": session_id, + } + ], + ) + + if promoted_from: + suggestions.dismiss(promoted_from) + + return { + "status": "ok", + "entry_id": store_result["entry_id"], + "superseded_id": store_result.get("superseded_id"), + "promoted_from": promoted_from, + "total_memories": await asyncio.to_thread(memory.count), + } + + +async def _handle_kb_get_memories( + arguments: dict, + *, + memory: ExplicitMemory, + suggestions: SuggestionQueue, +) -> dict: + entries = await asyncio.to_thread(memory.get_all) + pending = suggestions.get_all() + result = {"status": "ok", "count": len(entries), "memories": entries} + if pending: + result["suggestions"] = pending + result["suggestion_count"] = len(pending) + return result + + +async def _handle_kb_get_suggestions( + arguments: dict, + *, + suggestions: SuggestionQueue, +) -> dict: + pending = suggestions.get_all() + return {"status": "ok", "count": len(pending), "suggestions": pending} + + +async def _handle_kb_dismiss_suggestion( + arguments: dict, + *, + suggestions: SuggestionQueue, +) -> dict: + suggestion_id = arguments["suggestion_id"] + dismissed = suggestions.dismiss(suggestion_id) + if not dismissed: + return {"status": "error", "error": f"Suggestion not found: {suggestion_id}"} + return {"status": "ok", "dismissed": suggestion_id, "remaining": suggestions.count} + + +# --------------------------------------------------------------------------- +# Tool defs + handler map (used by the merged server and the test wrapper) +# --------------------------------------------------------------------------- + + +def _memory_tools_and_handlers( + kb: KnowledgeBase, + runtime_dir: str | Path | None = None, +): + """Build the explicit-memory ``(tool defs, handler map)``. + + Combined with the other concern modules' tools under one MCP ``Server`` by + :func:`dsagt.mcp.server.create_dsagt_server`. ``ExplicitMemory`` + + ``SuggestionQueue`` are rooted at ``runtime_dir`` (falling back to the KB + index's parent), matching the project's ``.dsagt`` memory location. + """ + mem_dir = Path(runtime_dir) if runtime_dir else kb.index_dir.parent + memory = ExplicitMemory(runtime_dir=mem_dir) + suggestions = SuggestionQueue(mem_dir / "suggestions.json") + + handlers = { + "kb_remember": partial( + _handle_kb_remember, kb=kb, memory=memory, suggestions=suggestions + ), + "kb_get_memories": partial( + _handle_kb_get_memories, memory=memory, suggestions=suggestions + ), + "kb_get_suggestions": partial( + _handle_kb_get_suggestions, suggestions=suggestions + ), + "kb_dismiss_suggestion": partial( + _handle_kb_dismiss_suggestion, suggestions=suggestions + ), + } + + tools = [ + types.Tool( + name="kb_remember", + description=( + "Store a user-confirmed fact as an explicit memory. " + "These persist across sessions. Use 'supersedes' to replace an outdated memory." + ), + inputSchema={ + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "The fact to remember", + }, + "category": { + "type": "string", + "description": "Classification tag", + }, + "session_id": { + "type": "string", + "description": "Current session identifier", + }, + "supersedes": { + "type": "string", + "description": "entry_id of an existing memory this replaces", + }, + "promoted_from": { + "type": "string", + "description": "suggestion_id if promoted from outlier suggestion", + }, + }, + "required": ["text"], + }, + ), + types.Tool( + name="kb_get_memories", + description=( + "Get all active explicit memories for this project. " + "Call at session start to load project context." + ), + inputSchema={"type": "object", "properties": {}}, + ), + types.Tool( + name="kb_get_suggestions", + description=( + "Get pending memory suggestions flagged by outlier detection. " + "Present to user for confirmation or dismissal." + ), + inputSchema={"type": "object", "properties": {}}, + ), + types.Tool( + name="kb_dismiss_suggestion", + description="Dismiss a pending memory suggestion.", + inputSchema={ + "type": "object", + "properties": { + "suggestion_id": { + "type": "string", + "description": "ID of the suggestion to dismiss", + }, + }, + "required": ["suggestion_id"], + }, + ), + ] + return tools, handlers + + +def create_memory_server( + kb: KnowledgeBase, + runtime_dir: str | Path | None = None, +): + """Create a standalone MCP server exposing only the explicit-memory tools. + + Test-facing API: tests call it with a mock KB and drive the server via + ``call_tool_sync()``. The merged ``dsagt-server`` uses + :func:`_memory_tools_and_handlers` directly instead of this wrapper. + """ + tools, handlers = _memory_tools_and_handlers(kb, runtime_dir) + return build_dispatch_server("memory", tools, handlers) diff --git a/src/dsagt/commands/registry_server.py b/src/dsagt/mcp/registry_tools.py similarity index 59% rename from src/dsagt/commands/registry_server.py rename to src/dsagt/mcp/registry_tools.py index 24d29b6..d77fc7e 100644 --- a/src/dsagt/commands/registry_server.py +++ b/src/dsagt/mcp/registry_tools.py @@ -1,24 +1,24 @@ -""" -DSAgt Registry MCP Server. - -Provides tools for building a tool registry by reading documentation, -fetching web resources, and running commands to extract tool specifications. - -Tool specs are saved as skill markdown files in the runtime skills directory -and indexed into a ChromaDB collection for semantic search. - -Server configuration (embedding credentials) flows through env vars -(LLM_API_KEY, OPENAI_BASE_URL, EMBEDDING_MODEL) set by dsagt start. - -These tool definitions + handlers run inside the merged ``dsagt-server`` -(see ``dsagt.commands.dsagt_server``). There is no standalone registry-server -entry point; ``create_registry_server`` is retained only as a test-facing -constructor. +"""MCP tools for the tool registry, execution, and provenance. + +The "tool lifecycle" surface of ``dsagt-server``: define a tool spec +(``save_tool_spec``), discover tools (``get_registry`` / ``search_registry``), +execute / gather (``read_file`` / ``http_request`` / ``run_command`` / +``install_dependencies``), and reconstruct a reproducible pipeline from the +recorded executions (``reconstruct_pipeline``). + +Tool specs are saved as markdown files in the runtime tools directory and +indexed into a ChromaDB collection for semantic search. Server configuration +(embedding credentials) flows through env vars (LLM_API_KEY, OPENAI_BASE_URL, +EMBEDDING_MODEL) set by ``dsagt start``. + +These definitions + handlers run inside the merged ``dsagt-server`` (see +:mod:`dsagt.mcp.server`); ``create_registry_server`` is retained only as a +test-facing constructor. Skill tools (``save_skill`` / ``search_skills`` / +``install_skill``) live in :mod:`dsagt.mcp.skill_tools`. """ import json import logging -import os import subprocess import sys from functools import partial @@ -27,12 +27,10 @@ import httpx import yaml -import mcp.server.stdio import mcp.types as types -from mcp.server.lowlevel import Server, NotificationOptions -from mcp.server.models import InitializationOptions from dsagt.knowledge import KnowledgeBase +from dsagt.mcp.server import build_dispatch_server from dsagt.observability import ( obs, registry_install_deps_span, @@ -40,38 +38,11 @@ registry_save_tool_span, ) from dsagt.provenance import reconstruct_pipeline -from dsagt.registry import ( - TOOLS_COLLECTION, - SkillRegistry, - ToolRegistry, -) - -os.environ["PYTHONUNBUFFERED"] = "1" +from dsagt.registry import TOOLS_COLLECTION, ToolRegistry logger = logging.getLogger(__name__) -# --------------------------------------------------------------------------- -# MCP server helpers -# --------------------------------------------------------------------------- - - -async def _run_stdio(server: Server, name: str): - async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - InitializationOptions( - server_name=name, - server_version="0.1.0", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), - ), - ) - - def _install_dependencies(packages: list[str], timeout: int = 120) -> str: """Install packages using uv pip install. Returns a status string.""" cmd = ["uv", "pip", "install", "--python", sys.executable] + packages @@ -197,44 +168,6 @@ async def _handle_save_tool_spec( return message -async def _handle_save_skill( - arguments: dict, - *, - skill_registry: SkillRegistry, -) -> str: - """Register a skill (workflow / agent instructions) for later reuse. - - Writes SKILL.md to ``/skills//``, where every supported - agent natively auto-discovers it (after the next ``dsagt start`` mirror). - No KB indexing — ``search_skills`` covers only the not-yet-installed - *catalog* tier, since installed skills are already natively discoverable. - """ - spec = arguments["spec"] - if isinstance(spec, str): - try: - spec = json.loads(spec) - except json.JSONDecodeError as e: - return f"Error: spec must be a JSON object (or string-encoded JSON object): {e}" - body = arguments.get("body") - reference_files = arguments.get("reference_files") - if isinstance(reference_files, str): - try: - reference_files = json.loads(reference_files) - except json.JSONDecodeError as e: - return f"Error: reference_files must be a JSON object: {e}" - try: - action = skill_registry.save_skill( - spec, body=body, reference_files=reference_files - ) - except (KeyError, ValueError, OSError) as e: - return f"Error saving skill: {e}" - skill_count = len(skill_registry.list_skills()) - return ( - f"Skill '{spec['name']}' {action} successfully. " - f"Registry now contains {skill_count} skills." - ) - - async def _handle_get_registry( arguments: dict, *, @@ -301,72 +234,6 @@ async def _handle_search_registry( return f"Found {len(results)} tool(s):\n\n" + "\n\n".join(summaries) -async def _handle_search_skills( - arguments: dict, - *, - kb: KnowledgeBase | None, - skill_registry: SkillRegistry | None, -) -> str: - if skill_registry is None: - return "search_skills is unavailable (no skill registry configured)." - - from dsagt.skill_discovery import SkillRouter - - router = SkillRouter(skill_registry=skill_registry, kb=kb) - return router.search( - arguments.get("query", ""), - top_k=arguments.get("top_k", 10), - tag=arguments.get("tag"), - skill_name=arguments.get("skill_name"), - ) - - -async def _handle_install_skill( - arguments: dict, - *, - runtime_dir: Path, -) -> str: - """Install a catalog skill into ``/skills//``. - - The skill's files land on disk immediately, so the agent can use it in the - current session by reading its SKILL.md. *Native* auto-invocation requires - the next ``dsagt start`` (which mirrors installed skills into - ``.claude/skills/`` before launch) plus an agent restart. - """ - from dsagt.skill_discovery import SkillRouter - - name = arguments.get("skill_name") - if not name: - return "install_skill requires 'skill_name'." - try: - info = SkillRouter().install(name, runtime_dir) - except LookupError as e: - return f"Error: {e}" - - # No KB re-index: the installed skill now lives in /skills/ where - # the agent natively auto-discovers it (after the next `dsagt start` mirror). - # search_skills covers only the not-yet-installed catalog tier. - attribution = info.get("attribution") or [] - attribution_note = ( - f"\nPreserved upstream license/attribution ({', '.join(attribution)}) " - f"+ PROVENANCE.txt." - if attribution - else "\nWrote PROVENANCE.txt recording the source." - ) - - return ( - f"{info['action'].capitalize()} skill '{info['name']}' at " - f"{info['dest_dir']}.\n\n" - f"Usable now — its SKILL.md and any scripts/references are already on " - f"disk in this project. To use it this session, read " - f"{info['dest_dir']}/SKILL.md and follow it; you don't need to restart.\n" - f"Restart is only for hands-free auto-invocation: the next `dsagt start` " - f"mirrors it into the platform's native skill dir (.claude/skills/), and " - f"after relaunch the agent discovers and auto-invokes it without this tool." - f"{attribution_note}" - ) - - async def _handle_reconstruct_pipeline( arguments: dict, *, @@ -424,40 +291,28 @@ async def _handle_install_dependencies( # --------------------------------------------------------------------------- -# Server factory (thin wiring — used by main() and tests) +# Tool defs + handler map (used by the merged server and the test wrapper) # --------------------------------------------------------------------------- def _registry_tools_and_handlers( registry: ToolRegistry, kb: KnowledgeBase | None = None, - skill_registry: SkillRegistry | None = None, ): - """Build the registry server's ``(tool defs, handler map)``. + """Build the registry/execution/provenance ``(tool defs, handler map)``. - Split out from :func:`create_registry_server` so the merged - ``dsagt-server`` can combine these with the knowledge server's tools - under one MCP ``Server`` without re-declaring them. + Combined with the other concern modules' tools under one MCP ``Server`` by + :func:`dsagt.mcp.server.create_dsagt_server`. """ runtime_dir = Path(registry.runtime_dir) - # Dispatch table — maps MCP tool names to handler functions with - # dependencies bound via functools.partial. handlers = { "read_file": _handle_read_file, "http_request": _handle_http_request, "run_command": _handle_run_command, "save_tool_spec": partial(_handle_save_tool_spec, registry=registry), - "save_skill": partial(_handle_save_skill, skill_registry=skill_registry), "get_registry": partial(_handle_get_registry, registry=registry), "search_registry": partial(_handle_search_registry, registry=registry, kb=kb), - "search_skills": partial( - _handle_search_skills, kb=kb, skill_registry=skill_registry - ), - "install_skill": partial( - _handle_install_skill, - runtime_dir=runtime_dir, - ), "reconstruct_pipeline": partial( _handle_reconstruct_pipeline, runtime_dir=runtime_dir ), @@ -605,74 +460,6 @@ def _registry_tools_and_handlers( "required": ["spec"], }, ), - types.Tool( - name="save_skill", - description=( - "Register a skill (agent workflow / instructions) into " - "/skills//SKILL.md, where the agent natively " - "auto-discovers it after the next `dsagt start`. Symmetric " - "with save_tool_spec — use this when you've designed a " - "reusable instruction set you want future sessions to load " - "automatically." - ), - inputSchema={ - "type": "object", - "properties": { - # ``anyOf`` for spec mirrors save_tool_spec — accept - # both structured object and JSON-encoded string for - # MCP clients that serialize nested args. - "spec": { - "description": "Skill spec (object or JSON-encoded string)", - "anyOf": [ - { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Unique skill identifier (becomes the directory name)", - }, - "description": { - "type": "string", - "description": "What the skill does / when to use it", - }, - "tags": { - "type": "array", - "items": {"type": "string"}, - "description": "Tags for categorizing the skill", - }, - }, - "required": ["name", "description"], - }, - {"type": "string"}, - ], - }, - "body": { - "type": "string", - "description": ( - "Markdown body of the SKILL.md (workflow / " - "instructions the agent will follow). When " - "updating an existing skill, omit to preserve " - "the existing body." - ), - }, - "reference_files": { - "description": ( - "Optional additional files to write into the " - "skill directory. Object mapping relative " - "path -> file contents, or JSON-encoded string." - ), - "anyOf": [ - { - "type": "object", - "additionalProperties": {"type": "string"}, - }, - {"type": "string"}, - ], - }, - }, - "required": ["spec"], - }, - ), types.Tool( name="get_registry", description="Get all tools from the registry", @@ -694,44 +481,6 @@ def _registry_tools_and_handlers( }, }, ), - types.Tool( - name="search_skills", - description=( - "Search agent skills by name, tag, or description. Spans installed " - "skills and the external installable catalog. Catalog hits are marked " - "'[catalog]' — use install_skill to add one to this project." - ), - inputSchema={ - "type": "object", - "properties": { - "query": {"type": "string", "description": "Search query"}, - "tag": {"type": "string", "description": "Filter by tag"}, - "skill_name": { - "type": "string", - "description": "Exact skill name lookup", - }, - "top_k": {"type": "integer", "default": 10}, - }, - }, - ), - types.Tool( - name="install_skill", - description=( - "Install a skill from the external catalog (found via search_skills) " - "into this project so the agent can use it natively. Copies SKILL.md " - "+ scripts/references; available natively after the next restart." - ), - inputSchema={ - "type": "object", - "properties": { - "skill_name": { - "type": "string", - "description": "Catalog skill name to install", - }, - }, - "required": ["skill_name"], - }, - ), types.Tool( name="reconstruct_pipeline", description="Reconstruct a reproducible pipeline script from tool execution records.", @@ -766,37 +515,12 @@ def _registry_tools_and_handlers( def create_registry_server( registry: ToolRegistry, kb: KnowledgeBase | None = None, - skill_registry: SkillRegistry | None = None, ): - """Create and configure the standalone MCP registry server. + """Create a standalone MCP server exposing only the registry/exec/provenance tools. - Test-facing API: tests call with a mock registry and get back a server - they can drive via call_tool_sync(). The merged ``dsagt-server`` uses + Test-facing API: tests call with a mock registry and drive the server via + ``call_tool_sync()``. The merged ``dsagt-server`` uses :func:`_registry_tools_and_handlers` directly instead of this wrapper. """ - server = Server("registry") - tools, handlers = _registry_tools_and_handlers(registry, kb, skill_registry) - - @server.list_tools() - async def list_tools() -> list[types.Tool]: - return tools - - @server.call_tool() - async def call_tool(name: str, arguments: dict) -> list[types.TextContent]: - handler = handlers[name] # KeyError = bug in list_tools schema - text = await handler(arguments) - return [types.TextContent(type="text", text=text)] - - return server - - -# --------------------------------------------------------------------------- -# Entry point -# --------------------------------------------------------------------------- -# -# There is no standalone ``dsagt-registry-server`` entry point any more. The -# registry tools run inside the merged ``dsagt-server`` (see -# ``dsagt.commands.dsagt_server``), which composes -# :func:`_registry_tools_and_handlers` with the knowledge server's tools under -# one MCP ``Server`` + one shared ``KnowledgeBase``. ``create_registry_server`` -# above is retained as a standalone, test-facing constructor. + tools, handlers = _registry_tools_and_handlers(registry, kb) + return build_dispatch_server("registry", tools, handlers) diff --git a/src/dsagt/commands/dsagt_server.py b/src/dsagt/mcp/server.py similarity index 64% rename from src/dsagt/commands/dsagt_server.py rename to src/dsagt/mcp/server.py index b474d00..df46cee 100644 --- a/src/dsagt/commands/dsagt_server.py +++ b/src/dsagt/mcp/server.py @@ -11,9 +11,14 @@ → ``dsagt-run`` subprocess; ``kb_ingest`` → background job thread), so collapsing the two processes costs little isolation. -Tool *definitions* and *handlers* still live in their concern modules -(``registry_server`` / ``knowledge_server``); this module only composes their -``(tools, handlers)`` under one dispatch shell and owns the shared-KB startup. +Tool *definitions* and *handlers* live in their concern modules +(:mod:`~dsagt.mcp.registry_tools` / :mod:`~dsagt.mcp.knowledge_tools` / +:mod:`~dsagt.mcp.memory_tools` / :mod:`~dsagt.mcp.skill_tools`); this module only +composes their ``(tools, handlers)`` under one dispatch shell +(:func:`build_dispatch_server`) and owns the shared-KB startup. The factory +imports are *lazy* (inside :func:`create_dsagt_server` / :func:`main`) so the +concern modules can import :func:`build_dispatch_server` from here without a +cycle. See ``design-notes/skills-catalog-server-merge.md`` §2. @@ -23,72 +28,63 @@ upgrade note in the README. """ -import asyncio -import json -import logging import os -from pathlib import Path -import yaml +# Set before any import that may pull in FAISS / PyTorch / sentence-transformers +# (e.g. ``dsagt.knowledge`` below): prevents a fatal OpenMP crash when multiple +# libraries each bundle their own libomp. +os.environ["PYTHONUNBUFFERED"] = "1" +os.environ.setdefault("KMP_DUPLICATE_LIB_OK", "TRUE") -import mcp.types as types -from mcp.server.lowlevel import Server +import asyncio # noqa: E402 +import json # noqa: E402 +import logging # noqa: E402 +from pathlib import Path # noqa: E402 -from dsagt.commands.knowledge_server import _knowledge_tools_and_handlers -from dsagt.commands.registry_server import ( - _registry_tools_and_handlers, - _run_stdio, -) -from dsagt.knowledge import KnowledgeBase -from dsagt.registry import SkillRegistry, ToolRegistry +import yaml # noqa: E402 -os.environ["PYTHONUNBUFFERED"] = "1" +import mcp.server.stdio # noqa: E402 +import mcp.types as types # noqa: E402 +from mcp.server.lowlevel import Server, NotificationOptions # noqa: E402 +from mcp.server.models import InitializationOptions # noqa: E402 + +from dsagt.knowledge import KnowledgeBase # noqa: E402 +from dsagt.registry import SkillRegistry, ToolRegistry # noqa: E402 logger = logging.getLogger(__name__) -def create_dsagt_server( - registry: ToolRegistry, - kb: KnowledgeBase | None, - skill_registry: SkillRegistry | None, - runtime_dir: str | Path | None = None, -): - """Compose the registry + knowledge tools under one MCP ``Server``. +# --------------------------------------------------------------------------- +# Shared dispatch shell (used by the merged server *and* the per-concern +# test-facing ``create_*_server`` wrappers) +# --------------------------------------------------------------------------- - Test-facing API: build the two registries + a (mock) KB, then drive the - returned server via ``call_tool_sync()``. ``main()`` constructs the real - deps from project config before calling this. - """ - server = Server("dsagt") - r_tools, r_handlers = _registry_tools_and_handlers(registry, kb, skill_registry) - k_tools, k_handlers = _knowledge_tools_and_handlers(kb, runtime_dir) +def build_dispatch_server(name: str, tools, handlers) -> Server: + """Wrap a ``(tools, handlers)`` pair in a configured MCP ``Server``. - tools = r_tools + k_tools - handlers = {**r_handlers, **k_handlers} - if len(handlers) != len(r_handlers) + len(k_handlers): - overlap = set(r_handlers) & set(k_handlers) - raise RuntimeError( - f"dsagt-server tool-name collision across modules: {overlap}" - ) + One dispatch contract for every concern module: catch + wrap errors, then + format by return type — a handler that returns ``str`` passes through, one + that returns ``dict`` is JSON-encoded. This is a superset of the old + per-server behavior (registry handlers returned ``str`` and never raised; + knowledge handlers returned ``dict`` and raised ``ValueError`` on bad + input), so it is behavior-preserving for both. + """ + server = Server(name) @server.list_tools() async def list_tools() -> list[types.Tool]: return tools @server.call_tool() - async def call_tool(name: str, arguments: dict) -> list[types.TextContent]: - handler = handlers[name] # KeyError = bug in list_tools schema - # Registry handlers return a plain string and never raise; knowledge - # handlers return a dict and raise ValueError on bad input. One wrapper - # serves both: catch + wrap errors (knowledge contract), then format by - # return type — str passes through, dict is JSON-encoded. + async def call_tool(tool_name: str, arguments: dict) -> list[types.TextContent]: + handler = handlers[tool_name] # KeyError = bug in list_tools schema try: result = await handler(arguments) except ValueError as e: result = {"status": "error", "error": str(e)} except Exception as e: - logger.exception("Unexpected error in tool '%s'", name) + logger.exception("Unexpected error in tool '%s'", tool_name) result = {"status": "error", "error": f"Unexpected error: {e}"} text = ( result @@ -100,6 +96,67 @@ async def call_tool(name: str, arguments: dict) -> list[types.TextContent]: return server +async def _run_stdio(server: Server, name: str) -> None: + async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + InitializationOptions( + server_name=name, + server_version="0.1.0", + capabilities=server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ), + ) + + +# --------------------------------------------------------------------------- +# Composition — merge the four concern modules' tools under one Server +# --------------------------------------------------------------------------- + + +def create_dsagt_server( + registry: ToolRegistry, + kb: KnowledgeBase | None, + skill_registry: SkillRegistry | None, + runtime_dir: str | Path | None = None, +): + """Compose the registry + knowledge + memory + skill tools under one ``Server``. + + Test-facing API: build the registries + a (mock) KB, then drive the + returned server via ``call_tool_sync()``. ``main()`` constructs the real + deps from project config before calling this. Factory imports are lazy to + keep the concern modules' top-level import of :func:`build_dispatch_server` + cycle-free. + """ + from dsagt.mcp.knowledge_tools import _knowledge_tools_and_handlers + from dsagt.mcp.memory_tools import _memory_tools_and_handlers + from dsagt.mcp.registry_tools import _registry_tools_and_handlers + from dsagt.mcp.skill_tools import _skill_tools_and_handlers + + groups = [ + _registry_tools_and_handlers(registry, kb), + _knowledge_tools_and_handlers(kb), + _memory_tools_and_handlers(kb, runtime_dir), + _skill_tools_and_handlers(skill_registry, kb, runtime_dir), + ] + + tools: list[types.Tool] = [] + handlers: dict = {} + for g_tools, g_handlers in groups: + overlap = set(handlers) & set(g_handlers) + if overlap: + raise RuntimeError( + f"dsagt-server tool-name collision across modules: {overlap}" + ) + tools += g_tools + handlers.update(g_handlers) + + return build_dispatch_server("dsagt", tools, handlers) + + def _build_kb_from_config(config: dict, project_dir: Path) -> KnowledgeBase: """Construct the one shared KnowledgeBase from project config. diff --git a/src/dsagt/mcp/skill_tools.py b/src/dsagt/mcp/skill_tools.py new file mode 100644 index 0000000..91bc140 --- /dev/null +++ b/src/dsagt/mcp/skill_tools.py @@ -0,0 +1,386 @@ +"""MCP tools for skill discovery, install, and catalog sources. + +The full skill surface of ``dsagt-server``, consolidated from the two former +servers: register a project skill (``save_skill``), enable + list external +catalog sources (``add_skill_source`` / ``list_skill_sources``), search the +catalog (``search_skills``), and install a catalog skill into the project +(``install_skill``). The catalog data plane + router live in +:mod:`dsagt.skills`; these handlers are the thin MCP wiring over it. + +Installed/created skills are natively auto-discovered by every supported agent, +so ``search_skills`` covers only the not-yet-installed *catalog* tier (plus the +no-embedder keyword fallback). + +These definitions + handlers run inside the merged ``dsagt-server`` (see +:mod:`dsagt.mcp.server`); ``create_skill_server`` is retained only as a +test-facing constructor. +""" + +import asyncio +import json +import logging +from functools import partial +from pathlib import Path + +import mcp.types as types + +from dsagt.knowledge import KnowledgeBase +from dsagt.mcp.server import build_dispatch_server +from dsagt.registry import SkillRegistry + +logger = logging.getLogger(__name__) + + +async def _handle_save_skill( + arguments: dict, + *, + skill_registry: SkillRegistry, +) -> str: + """Register a skill (workflow / agent instructions) for later reuse. + + Writes SKILL.md to ``/skills//``, where every supported + agent natively auto-discovers it (after the next ``dsagt start`` mirror). + No KB indexing — ``search_skills`` covers only the not-yet-installed + *catalog* tier, since installed skills are already natively discoverable. + """ + spec = arguments["spec"] + if isinstance(spec, str): + try: + spec = json.loads(spec) + except json.JSONDecodeError as e: + return f"Error: spec must be a JSON object (or string-encoded JSON object): {e}" + body = arguments.get("body") + reference_files = arguments.get("reference_files") + if isinstance(reference_files, str): + try: + reference_files = json.loads(reference_files) + except json.JSONDecodeError as e: + return f"Error: reference_files must be a JSON object: {e}" + try: + action = skill_registry.save_skill( + spec, body=body, reference_files=reference_files + ) + except (KeyError, ValueError, OSError) as e: + return f"Error saving skill: {e}" + skill_count = len(skill_registry.list_skills()) + return ( + f"Skill '{spec['name']}' {action} successfully. " + f"Registry now contains {skill_count} skills." + ) + + +async def _handle_search_skills( + arguments: dict, + *, + kb: KnowledgeBase | None, + skill_registry: SkillRegistry | None, +) -> str: + if skill_registry is None: + return "search_skills is unavailable (no skill registry configured)." + + from dsagt.skills import SkillRouter + + router = SkillRouter(skill_registry=skill_registry, kb=kb) + return router.search( + arguments.get("query", ""), + top_k=arguments.get("top_k", 10), + tag=arguments.get("tag"), + skill_name=arguments.get("skill_name"), + ) + + +async def _handle_install_skill( + arguments: dict, + *, + runtime_dir: Path, +) -> str: + """Install a catalog skill into ``/skills//``. + + The skill's files land on disk immediately, so the agent can use it in the + current session by reading its SKILL.md. *Native* auto-invocation requires + the next ``dsagt start`` (which mirrors installed skills into + ``.claude/skills/`` before launch) plus an agent restart. + """ + from dsagt.skills import SkillRouter + + name = arguments.get("skill_name") + if not name: + return "install_skill requires 'skill_name'." + try: + info = SkillRouter().install(name, runtime_dir) + except LookupError as e: + return f"Error: {e}" + + # Bare confirmation by design: the install→use→restart model and the + # license/PROVENANCE capture are already in the agent's instructions and on + # disk (PROVENANCE.txt), so repeating them on every install is just noise. + verb = "Updated" if info["action"] == "updated" else "Installed" + return f"{verb} '{info['name']}' → {info['dest_dir']}/" + + +async def _handle_add_skill_source( + arguments: dict, + *, + kb: KnowledgeBase, + runtime_dir: Path, +) -> dict: + """Enable a skill source (known name or GitHub URL): clone + index the catalog.""" + from dsagt.skills import ( + KNOWN_SOURCES, + SkillRouter, + persist_source_to_config, + resolve_source, + ) + + source = arguments.get("source") + if not source: + return { + "error": "add_skill_source requires 'source' (known name or GitHub URL)." + } + try: + spec = resolve_source(source) + if isinstance(source, str) and source in KNOWN_SOURCES: + spec.setdefault("name", source) + router = SkillRouter(kb=kb) + stats = await asyncio.to_thread(router.sync, source) + except (ValueError, RuntimeError) as e: + return {"error": str(e)} + persist_source_to_config( + runtime_dir, {"name": spec.get("name", stats["slug"]), **spec} + ) + return { + "source": spec["url"], + "slug": stats["slug"], + "skills_indexed": stats["indexed"], + "note": "Searchable via search_skills; install one with install_skill.", + } + + +async def _handle_list_skill_sources(arguments: dict, *, kb: KnowledgeBase) -> dict: + """List known skill sources, each flagged synced/available with its count. + + A source is ``synced`` (searchable via ``search_skills``) only after an + ``add_skill_source`` call has cloned + indexed it; otherwise it is + ``available`` (known name + URL, nothing indexed yet). Reporting the + flag + ``indexed`` count inline means the agent doesn't have to cross- + reference a separate ``synced_collections`` list to tell the difference. + """ + from dsagt.registry import CATALOG_COLLECTION_PREFIX, catalog_collection + from dsagt.skills import KNOWN_SOURCES, SkillRouter, _repo_slug + + synced = {c for c in kb.collections if c.startswith(CATALOG_COLLECTION_PREFIX)} + + # Single source of truth for the per-source synced/indexed view (shared + # with the CLI `skills list --catalog`). + sources = { + s["name"]: { + "url": s["url"], + "description": s["description"], + "synced": s["synced"], + "indexed": s["indexed"], + } + for s in SkillRouter(kb=kb).list_sources() + } + + # Surface any synced catalog whose source isn't in KNOWN_SOURCES (added + # by raw GitHub URL) so the count is never silently dropped. + known_colls = { + catalog_collection(_repo_slug(s["url"])) for s in KNOWN_SOURCES.values() + } + extra = sorted(synced - known_colls) + + any_synced = any(v["synced"] for v in sources.values()) or bool(extra) + return { + "sources": sources, + "other_synced_collections": extra, + "note": ( + "add_skill_source to sync a source whose synced=false; " + "then search_skills to browse. search_skills only sees synced sources." + if any_synced + else "No catalog synced yet — add_skill_source " + "(e.g. 'scientific') to enable one, then search_skills to browse." + ), + } + + +# --------------------------------------------------------------------------- +# Tool defs + handler map (used by the merged server and the test wrapper) +# --------------------------------------------------------------------------- + + +def _skill_tools_and_handlers( + skill_registry: SkillRegistry | None, + kb: KnowledgeBase | None = None, + runtime_dir: str | Path | None = None, +): + """Build the skill ``(tool defs, handler map)``. + + Combined with the other concern modules' tools under one MCP ``Server`` by + :func:`dsagt.mcp.server.create_dsagt_server`. ``runtime_dir`` (the project + dir, where skills install + sources persist) falls back to the skill + registry's ``runtime_dir`` then the KB index's parent. + """ + rt: Path | None = Path(runtime_dir) if runtime_dir else None + if rt is None and skill_registry is not None: + rt = Path(skill_registry.runtime_dir) + if rt is None and kb is not None: + rt = kb.index_dir.parent + + handlers = { + "save_skill": partial(_handle_save_skill, skill_registry=skill_registry), + "search_skills": partial( + _handle_search_skills, kb=kb, skill_registry=skill_registry + ), + "install_skill": partial(_handle_install_skill, runtime_dir=rt), + "add_skill_source": partial(_handle_add_skill_source, kb=kb, runtime_dir=rt), + "list_skill_sources": partial(_handle_list_skill_sources, kb=kb), + } + + tools = [ + types.Tool( + name="save_skill", + description=( + "Register a skill (agent workflow / instructions) into " + "/skills//SKILL.md, where the agent natively " + "auto-discovers it after the next `dsagt start`. Symmetric " + "with save_tool_spec — use this when you've designed a " + "reusable instruction set you want future sessions to load " + "automatically." + ), + inputSchema={ + "type": "object", + "properties": { + # ``anyOf`` for spec mirrors save_tool_spec — accept + # both structured object and JSON-encoded string for + # MCP clients that serialize nested args. + "spec": { + "description": "Skill spec (object or JSON-encoded string)", + "anyOf": [ + { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Unique skill identifier (becomes the directory name)", + }, + "description": { + "type": "string", + "description": "What the skill does / when to use it", + }, + "tags": { + "type": "array", + "items": {"type": "string"}, + "description": "Tags for categorizing the skill", + }, + }, + "required": ["name", "description"], + }, + {"type": "string"}, + ], + }, + "body": { + "type": "string", + "description": ( + "Markdown body of the SKILL.md (workflow / " + "instructions the agent will follow). When " + "updating an existing skill, omit to preserve " + "the existing body." + ), + }, + "reference_files": { + "description": ( + "Optional additional files to write into the " + "skill directory. Object mapping relative " + "path -> file contents, or JSON-encoded string." + ), + "anyOf": [ + { + "type": "object", + "additionalProperties": {"type": "string"}, + }, + {"type": "string"}, + ], + }, + }, + "required": ["spec"], + }, + ), + types.Tool( + name="add_skill_source", + description=( + "Enable an external agent-skill source (a known name like " + "'scientific'/'anthropic'/'antigravity'/'composio', or a GitHub URL). " + "Clones it and indexes its skills into the searchable catalog " + "(search_skills). Does NOT load them into context." + ), + inputSchema={ + "type": "object", + "properties": { + "source": { + "type": "string", + "description": "Known source name or GitHub repo URL / owner/repo", + }, + }, + "required": ["source"], + }, + ), + types.Tool( + name="list_skill_sources", + description="List known + synced external skill sources and their indexed catalogs.", + inputSchema={"type": "object", "properties": {}}, + ), + types.Tool( + name="search_skills", + description=( + "Search agent skills by name, tag, or description. Spans installed " + "skills and the external installable catalog. Catalog hits are marked " + "'[catalog]' — use install_skill to add one to this project." + ), + inputSchema={ + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search query"}, + "tag": {"type": "string", "description": "Filter by tag"}, + "skill_name": { + "type": "string", + "description": "Exact skill name lookup", + }, + "top_k": {"type": "integer", "default": 10}, + }, + }, + ), + types.Tool( + name="install_skill", + description=( + "Install a skill from the external catalog (found via search_skills) " + "into this project so the agent can use it natively. Copies SKILL.md " + "+ scripts/references; available natively after the next restart." + ), + inputSchema={ + "type": "object", + "properties": { + "skill_name": { + "type": "string", + "description": "Catalog skill name to install", + }, + }, + "required": ["skill_name"], + }, + ), + ] + return tools, handlers + + +def create_skill_server( + skill_registry: SkillRegistry | None = None, + kb: KnowledgeBase | None = None, + runtime_dir: str | Path | None = None, +): + """Create a standalone MCP server exposing only the skill tools. + + Test-facing API: tests call it with mock deps and drive the server via + ``call_tool_sync()``. The merged ``dsagt-server`` uses + :func:`_skill_tools_and_handlers` directly instead of this wrapper. + """ + tools, handlers = _skill_tools_and_handlers(skill_registry, kb, runtime_dir) + return build_dispatch_server("skills", tools, handlers) diff --git a/src/dsagt/observability.py b/src/dsagt/observability.py index 62405e3..55eb0f2 100644 --- a/src/dsagt/observability.py +++ b/src/dsagt/observability.py @@ -1,8 +1,8 @@ """ DSAgt observability — span emission via MLflow's native tracer provider. -Business modules (knowledge.py, provenance.py, registry_server.py, run_tool.py) -import the small public surface defined here. ``init_tracing`` installs +Business modules (knowledge.py, provenance.py, mcp/registry_tools.py, +run_tool.py) import the small public surface defined here. ``init_tracing`` installs MLflow's own ``TracerProvider`` as the OTel global, so every ``trace.get_tracer(...)`` call routes spans into MLflow's native trace store with full ``mlflow.spanInputs`` / ``mlflow.spanOutputs`` integration and @@ -60,7 +60,7 @@ # every span helper. DSAgt runs one process per project, so the session # id is a process-wide constant set at startup by init_tracing(), and # propagating it implicitly via this module-level value lets business -# code in knowledge.py / provenance.py / registry_server.py emit spans +# code in knowledge.py / provenance.py / mcp/registry_tools.py emit spans # without ever knowing about session ids. The cost is that tests have # to monkeypatch the global to isolate (see _reset_tracing fixture in # test_observability.py). diff --git a/src/dsagt/skill_discovery.py b/src/dsagt/skill_discovery.py deleted file mode 100644 index fe167c7..0000000 --- a/src/dsagt/skill_discovery.py +++ /dev/null @@ -1,110 +0,0 @@ -"""SkillRouter — the thin render/MCP facade over the skill *catalog*. - -The catalog data plane (clone cache + ChromaDB collections + backend -selection + the four ops) lives in -:class:`dsagt.commands.skills_catalog.SkillsCatalog`. ``SkillRouter`` adds only -the presentation concerns that the MCP handlers and the CLI share: rendering a -ranked hit list into the ``search_skills`` string, the empty-result message, and -the exact-``skill_name`` lookup (which needs the installed-skill registry, not -the catalog). - -Construct it from the same inputs at every call site (MCP ``search_skills``, CLI -``skills search/list``) so policy can't diverge between them — or hand it a -prebuilt :class:`SkillsCatalog` via ``catalog=`` so a server that already owns a -shared KB reuses one catalog instance. - -Skill *materialization* (mirroring installed skills into each agent's native -skills directory) lives in the agent layer (``AgentSetup.setup_skills``), not -here: every supported agent (claude/codex/goose/cline/roo) natively -auto-discovers ``SKILL.md`` folders, so there is no agent-facing disclosure tier -for the router to own. ``search_skills`` exists for the *catalog* tier (skills -not yet installed, which native discovery can't see) plus the no-embedder -keyword fallback. - -See ``design-notes/genesis-skills-comparison.md`` §7 and -``design-notes/skills-catalog-server-merge.md`` §1. -""" - -from __future__ import annotations - -import logging - -from dsagt.commands.skills_catalog import SkillsCatalog - -logger = logging.getLogger(__name__) - - -def _where_label(source: str) -> str: - """Human tag for a hit's origin, matching the legacy search output.""" - if source in ("bundled", "registered", "installed"): - return " [installed]" - if source.startswith("catalog:"): - return " [catalog · install_skill to add]" - return "" - - -class SkillRouter: - """Renders catalog discovery for the MCP ``search_skills`` tool + the CLI.""" - - def __init__(self, *, kb=None, skill_registry=None, cache_dir=None, catalog=None): - self._catalog = ( - catalog - if catalog is not None - else SkillsCatalog(kb=kb, cache_dir=cache_dir) - ) - self._reg = skill_registry - - # -- rendering ----------------------------------------------------------- - - def _render_search(self, hits: list[dict]) -> str: - lines = [] - for h in hits: - lines.append( - f"- **{h['name']}**{_where_label(h['source'])} " - f"(score: {h['score']:.2f})\n {h['summary']}" - ) - return f"Found {len(hits)} skill(s):\n\n" + "\n\n".join(lines) - - def _empty_message(self) -> str: - if not self._catalog.synced_collections() and self._catalog.has_kb: - return ( - "No catalog skills found. No external skill catalog is synced " - "yet — search covers the catalog (skills you can install), since " - "installed skills are already natively discoverable. Call " - "list_skill_sources() to see available sources, then " - "add_skill_source(source=...) to sync one before searching again." - ) - return "No catalog skills found matching the query." - - # -- public API ---------------------------------------------------------- - - def search(self, query=None, *, top_k: int = 8, tag=None, skill_name=None) -> str: - """Stage B. Select + render. Stateless — no session/exposure tracking.""" - if skill_name: - import yaml - - if self._reg is None: - return f"No skill named '{skill_name}'." - spec = self._reg.get_skill(skill_name) - if spec: - return f"Found skill '{skill_name}':\n\n" + yaml.dump( - spec, default_flow_style=False, sort_keys=False - ) - return f"No skill named '{skill_name}'." - - hits = self._catalog.search(query, top_k=top_k, tag=tag) - if not hits: - return self._empty_message() - return self._render_search(hits) - - def sync(self, source, *, force: bool = False) -> dict: - """Stage A passthrough — see :meth:`SkillsCatalog.sync`.""" - return self._catalog.sync(source, force=force) - - def install(self, name: str, project_dir) -> dict: - """Stage C passthrough — see :meth:`SkillsCatalog.install`.""" - return self._catalog.install(name, project_dir) - - def list_sources(self) -> list[dict]: - """Stage A view passthrough — see :meth:`SkillsCatalog.list_sources`.""" - return self._catalog.list_sources() diff --git a/src/dsagt/skill_keyword.py b/src/dsagt/skill_keyword.py deleted file mode 100644 index 2542c22..0000000 --- a/src/dsagt/skill_keyword.py +++ /dev/null @@ -1,101 +0,0 @@ -"""Zero-dependency keyword token-overlap scorer for skill discovery. - -A faithful reimplementation (not an import) of the Genesis Skills -``skill-search`` engine (``skill_search/catalog.py``: ``_score_skill`` / -``rank_skills``), Apache-2.0, gitlab.osti.gov/genesis/genesis-skills. This is -the fallback ranker used by :class:`dsagt.skill_discovery.SkillRouter` when no -embedder / KB is configured: keyword overlap only, stdlib only, deterministic. - -Scoring (per skill, against a query) — matching Genesis exactly: - -* +2 for each query token that also appears in the skill **name** -* +1 for each query token that also appears in the **description** -* then **at most one** substring bonus (mutually exclusive, in priority order): - +6 if the query equals the name, else +4 if it is a substring of the name, - else +2 if it is a substring of the description - -Tokens are casefolded ``\\w+`` runs with hyphens split, single-character tokens -and stopwords dropped. Ties break by name (ascending); below ``min_score`` are -dropped. -""" - -from __future__ import annotations - -import re - -#: Stopword set — kept identical to Genesis so ranking parity holds. -_STOPWORDS = frozenset( - { - "a", - "an", - "and", - "as", - "at", - "be", - "for", - "from", - "if", - "in", - "into", - "is", - "it", - "of", - "on", - "or", - "please", - "the", - "this", - "to", - "use", - "using", - "with", - } -) - -_TOKEN_RE = re.compile(r"\w+", flags=re.UNICODE) - - -def _tokens(text: str) -> set[str]: - """Casefolded word tokens (hyphens split), single-char + stopwords removed.""" - normalized = (text or "").casefold().replace("-", " ") - return { - t for t in _TOKEN_RE.findall(normalized) if len(t) > 1 and t not in _STOPWORDS - } - - -def score_skill(query: str, name: str, description: str) -> float: - """Token-overlap score of one skill against *query* (0.0 = no match).""" - qtokens = _tokens(query) - normalized_query = (query or "").casefold().strip() - if not qtokens and not normalized_query: - return 0.0 - - score = 2 * len(qtokens & _tokens(name)) + len(qtokens & _tokens(description)) - - if normalized_query: - name_l = (name or "").casefold() - if normalized_query == name_l: - score += 6 - elif normalized_query in name_l: - score += 4 - elif normalized_query in (description or "").casefold(): - score += 2 - return float(score) - - -def rank_skills( - query: str, skills, top_k: int | None = 8, min_score: int = 1 -) -> list[tuple[dict, float]]: - """Rank *skills* (dicts with ``name`` + ``description``) against *query*. - - Returns ``[(skill, score), ...]`` for skills scoring at least *min_score*, - sorted by score descending then name ascending, truncated to *top_k* (all - when *top_k* is ``None``). - """ - scored: list[tuple[dict, float]] = [] - for s in skills: - sc = score_skill(query, s.get("name", ""), s.get("description", "")) - if sc >= min_score: - scored.append((s, sc)) - scored.sort(key=lambda kv: (-kv[1], (kv[0].get("name") or ""))) - return scored[:top_k] if top_k is not None else scored diff --git a/src/dsagt/commands/skills_catalog.py b/src/dsagt/skills.py similarity index 63% rename from src/dsagt/commands/skills_catalog.py rename to src/dsagt/skills.py index d2b1d32..27627d5 100644 --- a/src/dsagt/commands/skills_catalog.py +++ b/src/dsagt/skills.py @@ -1,22 +1,38 @@ -""" -External skill catalog — fetch Agent-Skills repos, index, install. +"""Skill discovery — catalog data plane, keyword scorer, and the router facade. + +One module for all the importable skill-discovery logic that is *not* an entry +point (entry points — the MCP tool handlers — stay in +``commands/registry_server.py`` and ``commands/knowledge_server.py``). Three +cohesive concerns, in dependency order: + +1. **Keyword scorer** (:func:`score_skill` / :func:`rank_skills`) — a faithful + reimplementation of the Genesis Skills ``skill-search`` engine, the + zero-dependency fallback ranker used when no embedder / KB is configured. +2. **Catalog data plane** (:class:`SkillsCatalog` + its module functions) — + fetch Agent-Skills repos, index per-source into ``skills_catalog__`` + collections, search, and install into a project. +3. **Router facade** (:class:`SkillRouter`) — the thin render layer the MCP + ``search_skills`` tool and the ``dsagt skills`` CLI share. Two tiers (see the skill-management plan): -* **Catalog** — every skill in a configured GitHub source repo, indexed - into a per-source ``skills_catalog__`` KB collection. Searchable - via ``search_skills``, but NOT copied locally and NOT loaded into the - agent's context. This is the one job native skill discovery can't do - (you can't hold thousands of skill descriptions in context). -* **Installed** — a chosen skill copied into ``/skills//``. - The agent setup then mirrors it into ``.claude/skills/`` for native - discovery (see ``agents.base._mirror_skills_to``). +* **Catalog** — every skill in a configured source repo, indexed into a + per-source ``skills_catalog__`` KB collection. Searchable via + ``search_skills``, but NOT copied locally and NOT loaded into the agent's + context. This is the one job native skill discovery can't do (you can't hold + thousands of skill descriptions in context). +* **Installed** — a chosen skill copied into ``/skills//``. The + agent setup mirrors it into the agent's native skills dir for native + discovery (see ``agents.base.setup_skills``). Re-sync is idempotent by dropping the per-source collection directory and -rebuilding it — no delete-by-metadata primitive required. +rebuilding it. ``clone_github`` is imported lazily inside :func:`sync_source` +to avoid an import cycle with ``setup_core_kb`` (which calls back into +:func:`sync_source`). -``clone_github`` is imported lazily inside :func:`sync_source` to avoid an -import cycle with ``setup_core_kb`` (which calls back into ``sync_source``). +Genesis Skills: Apache-2.0, gitlab.osti.gov/genesis/genesis-skills +(``skill_search/catalog.py``). See ``design-notes/genesis-skills-comparison.md`` +and ``design-notes/skills-catalog-server-merge.md``. """ from __future__ import annotations @@ -36,6 +52,110 @@ logger = logging.getLogger(__name__) + +# =========================================================================== +# Keyword scorer — Genesis-derived token-overlap fallback (stdlib only) +# =========================================================================== +# +# A faithful reimplementation (not an import) of the Genesis Skills +# ``skill-search`` engine (``skill_search/catalog.py``: ``_score_skill`` / +# ``rank_skills``). Used by :class:`SkillsCatalog` when no embedder / KB is +# configured: keyword overlap only, deterministic. +# +# Scoring (per skill, against a query) — matching Genesis exactly: +# +# * +2 for each query token that also appears in the skill **name** +# * +1 for each query token that also appears in the **description** +# * then **at most one** substring bonus (mutually exclusive, in priority +# order): +6 if the query equals the name, else +4 if it is a substring of +# the name, else +2 if it is a substring of the description +# +# Tokens are casefolded ``\w+`` runs with hyphens split, single-character +# tokens and stopwords dropped. Ties break by name (ascending); below +# ``min_score`` are dropped. + +#: Stopword set — kept identical to Genesis so ranking parity holds. +_STOPWORDS = frozenset( + { + "a", + "an", + "and", + "as", + "at", + "be", + "for", + "from", + "if", + "in", + "into", + "is", + "it", + "of", + "on", + "or", + "please", + "the", + "this", + "to", + "use", + "using", + "with", + } +) + +_TOKEN_RE = re.compile(r"\w+", flags=re.UNICODE) + + +def _tokens(text: str) -> set[str]: + """Casefolded word tokens (hyphens split), single-char + stopwords removed.""" + normalized = (text or "").casefold().replace("-", " ") + return { + t for t in _TOKEN_RE.findall(normalized) if len(t) > 1 and t not in _STOPWORDS + } + + +def score_skill(query: str, name: str, description: str) -> float: + """Token-overlap score of one skill against *query* (0.0 = no match).""" + qtokens = _tokens(query) + normalized_query = (query or "").casefold().strip() + if not qtokens and not normalized_query: + return 0.0 + + score = 2 * len(qtokens & _tokens(name)) + len(qtokens & _tokens(description)) + + if normalized_query: + name_l = (name or "").casefold() + if normalized_query == name_l: + score += 6 + elif normalized_query in name_l: + score += 4 + elif normalized_query in (description or "").casefold(): + score += 2 + return float(score) + + +def rank_skills( + query: str, skills, top_k: int | None = 8, min_score: int = 1 +) -> list[tuple[dict, float]]: + """Rank *skills* (dicts with ``name`` + ``description``) against *query*. + + Returns ``[(skill, score), ...]`` for skills scoring at least *min_score*, + sorted by score descending then name ascending, truncated to *top_k* (all + when *top_k* is ``None``). + """ + scored: list[tuple[dict, float]] = [] + for s in skills: + sc = score_skill(query, s.get("name", ""), s.get("description", "")) + if sc >= min_score: + scored.append((s, sc)) + scored.sort(key=lambda kv: (-kv[1], (kv[0].get("name") or ""))) + return scored[:top_k] if top_k is not None else scored + + +# =========================================================================== +# Catalog data plane — sources, sync/index, lookup/install, SkillsCatalog +# =========================================================================== + #: Default source enabled out of the box (matches dsagt_config.yaml default). DEFAULT_SOURCE = "scientific" @@ -424,8 +544,7 @@ class SkillsCatalog: ``sync`` / ``search`` / ``install`` / ``list_sources``. The skill-specific behavior (frontmatter-indexed catalog collections, the no-embedder keyword fallback over the clone cache) lives here; the vector store + embedder are - the shared KB. :class:`~dsagt.skill_discovery.SkillRouter` is a thin - render/MCP facade over this. + the shared KB. :class:`SkillRouter` is a thin render/MCP facade over this. Catalog tier only: installed/created skills are natively auto-discovered by every supported agent, so they are never search candidates. ``search`` @@ -434,14 +553,23 @@ class SkillsCatalog: """ def __init__(self, *, kb=None, cache_dir: Path | None = None): + """Compose a catalog over an existing KB + a clone-cache directory. + + ``kb`` is the host server's :class:`~dsagt.knowledge.KnowledgeBase` (so + the embedder/Chroma are shared, never a second model load); pass ``None`` + for the no-embedder keyword path. ``cache_dir`` overrides the default + machine-global clone cache (:data:`SKILL_SOURCES_DIR`) — handy for tests. + """ self._kb = kb self._cache_dir = cache_dir # default resolved lazily @property def has_kb(self) -> bool: + """True when an embedder-backed KB is available (vs the keyword path).""" return self._kb is not None def _resolved_cache_dir(self) -> Path: + """The clone-cache dir — the ``cache_dir`` override or the global default.""" return Path(self._cache_dir or SKILL_SOURCES_DIR) # -- write ops (delegate to the module functions) ------------------------ @@ -479,6 +607,14 @@ def search(self, query=None, *, top_k: int = 8, tag=None) -> list[dict]: return self._select_keyword(query, top_k, tag) def _select_kb(self, query, top_k: int, tag) -> list[dict]: + """Semantic backend: ChromaDB search across every synced catalog + collection, merged + sorted by score into normalized hit dicts. + + Each ``skills_catalog__*`` collection is queried independently (a + missing/corrupt one is skipped, not fatal); when a ``tag`` filter is + set we over-fetch (``top_k * 3``) then post-filter so the tag doesn't + starve the result set. + """ collections = self.synced_collections() fetch_k = top_k * 3 if tag else top_k hits: list[dict] = [] @@ -531,8 +667,13 @@ def _candidate_skills(self) -> list[dict]: return cands def _select_keyword(self, query, top_k: int, tag) -> list[dict]: - from dsagt import skill_keyword # lazy: keep import graph light + """No-embedder backend: rank cached-catalog skills with the Genesis + token-overlap scorer (:func:`rank_skills`) into normalized hit dicts. + With no ``query`` there's nothing to score, so it returns the first + ``top_k`` candidates (tag-filtered) at score 0.0 — a browse mode rather + than a search. + """ cands = self._candidate_skills() if tag: cands = [c for c in cands if tag in (c["tags"] or "")] @@ -542,7 +683,7 @@ def _select_keyword(self, query, top_k: int, tag) -> list[dict]: {**c, "summary": (c["description"] or "")[:200], "score": 0.0} for c in picks ] - ranked = skill_keyword.rank_skills(query, cands, top_k=top_k) + ranked = rank_skills(query, cands, top_k=top_k) return [ {**c, "summary": (c["description"] or "")[:200], "score": sc} for c, sc in ranked @@ -569,6 +710,11 @@ def list_sources(self) -> list[dict]: return out def _indexed_count(self, collection: str) -> int: + """Number of skills indexed in a catalog *collection* (0 if absent/no KB). + + Reads the collection's persisted ``chroma_ids.json`` directly rather than + querying the store — cheap, and works without loading the embedder. + """ if self._kb is None: return 0 ids = Path(self._kb.index_dir) / collection / "chroma_ids.json" @@ -576,3 +722,122 @@ def _indexed_count(self, collection: str) -> int: return len(json.loads(ids.read_text())) except (FileNotFoundError, ValueError, OSError): return 0 + + +# =========================================================================== +# SkillRouter — the thin render/MCP facade over the catalog +# =========================================================================== +# +# ``SkillRouter`` adds only the presentation concerns that the MCP handlers and +# the CLI share: rendering a ranked hit list into the ``search_skills`` string, +# the empty-result message, and the exact-``skill_name`` lookup (which needs the +# installed-skill registry, not the catalog). +# +# Construct it from the same inputs at every call site (MCP ``search_skills``, +# CLI ``skills search/list``) so policy can't diverge between them — or hand it +# a prebuilt :class:`SkillsCatalog` via ``catalog=`` so a server that already +# owns a shared KB reuses one catalog instance. +# +# Skill *materialization* (mirroring installed skills into each agent's native +# skills directory) lives in the agent layer (``AgentSetup.setup_skills``), not +# here: every supported agent (claude/codex/goose/cline/roo) natively +# auto-discovers ``SKILL.md`` folders, so there is no agent-facing disclosure +# tier for the router to own. ``search_skills`` exists for the *catalog* tier +# (skills not yet installed, which native discovery can't see) plus the +# no-embedder keyword fallback. + + +def _where_label(source: str) -> str: + """Human tag for a hit's origin, matching the legacy search output.""" + if source in ("bundled", "registered", "installed"): + return " [installed]" + if source.startswith("catalog:"): + return " [catalog · install_skill to add]" + return "" + + +class SkillRouter: + """Renders catalog discovery for the MCP ``search_skills`` tool + the CLI.""" + + def __init__(self, *, kb=None, skill_registry=None, cache_dir=None, catalog=None): + """Wire the router to a catalog + (optional) installed-skill registry. + + Pass a prebuilt ``catalog`` (a :class:`SkillsCatalog`) to share one + instance with the server; otherwise one is constructed from ``kb`` / + ``cache_dir``. ``skill_registry`` is only consulted for the exact + ``skill_name`` lookup in :meth:`search` (installed skills live there, + not in the catalog), so it may be ``None`` for catalog-only callers. + """ + self._catalog = ( + catalog + if catalog is not None + else SkillsCatalog(kb=kb, cache_dir=cache_dir) + ) + self._reg = skill_registry + + # -- rendering ----------------------------------------------------------- + + def _render_search(self, hits: list[dict]) -> str: + """Format ranked catalog hits into the ``search_skills`` markdown list. + + Each line carries the skill name, an origin tag (:func:`_where_label`), + the score, and the summary — the human-facing string the MCP tool and + CLI both return. + """ + lines = [] + for h in hits: + lines.append( + f"- **{h['name']}**{_where_label(h['source'])} " + f"(score: {h['score']:.2f})\n {h['summary']}" + ) + return f"Found {len(hits)} skill(s):\n\n" + "\n\n".join(lines) + + def _empty_message(self) -> str: + """The no-results string, tailored to *why* nothing matched. + + When a KB exists but no catalog source is synced yet, point the agent + at ``list_skill_sources`` / ``add_skill_source`` (the likely cause); + otherwise it's a genuine no-match for the query. + """ + if not self._catalog.synced_collections() and self._catalog.has_kb: + return ( + "No catalog skills found. No external skill catalog is synced " + "yet — search covers the catalog (skills you can install), since " + "installed skills are already natively discoverable. Call " + "list_skill_sources() to see available sources, then " + "add_skill_source(source=...) to sync one before searching again." + ) + return "No catalog skills found matching the query." + + # -- public API ---------------------------------------------------------- + + def search(self, query=None, *, top_k: int = 8, tag=None, skill_name=None) -> str: + """Stage B. Select + render. Stateless — no session/exposure tracking.""" + if skill_name: + import yaml + + if self._reg is None: + return f"No skill named '{skill_name}'." + spec = self._reg.get_skill(skill_name) + if spec: + return f"Found skill '{skill_name}':\n\n" + yaml.dump( + spec, default_flow_style=False, sort_keys=False + ) + return f"No skill named '{skill_name}'." + + hits = self._catalog.search(query, top_k=top_k, tag=tag) + if not hits: + return self._empty_message() + return self._render_search(hits) + + def sync(self, source, *, force: bool = False) -> dict: + """Stage A passthrough — see :meth:`SkillsCatalog.sync`.""" + return self._catalog.sync(source, force=force) + + def install(self, name: str, project_dir) -> dict: + """Stage C passthrough — see :meth:`SkillsCatalog.install`.""" + return self._catalog.install(name, project_dir) + + def list_sources(self) -> list[dict]: + """Stage A view passthrough — see :meth:`SkillsCatalog.list_sources`.""" + return self._catalog.list_sources() diff --git a/src/session.py b/src/session.py deleted file mode 100755 index bfb9d64..0000000 --- a/src/session.py +++ /dev/null @@ -1,458 +0,0 @@ -""" -DSAGT session management. - -Handles project initialization, agent-specific config generation, -and service lifecycle (proxy + MLflow). - -Each agent platform gets its configs generated from the single -dsagt_config.yaml — MCP server entries, agent instructions, env vars, -and proxy routing are all derived mechanically. -""" - -import json -import logging -import os -import signal -import subprocess -import sys -from pathlib import Path - -import yaml - -from dsagt.config import ( - VALID_AGENTS, - default_config_content, - load_config, - project_dir_for, -) - -logger = logging.getLogger(__name__) - -# Where bundled agent instruction templates live (relative to dsagt package) -_AGENTS_DIR = Path(__file__).parent.parent.parent / "agents" - - -# --------------------------------------------------------------------------- -# Project initialization -# --------------------------------------------------------------------------- - -def init_project(project_name: str, agent: str, runtime_base: str | Path = "runtime") -> Path: - """Create a new project directory with default config and subdirectories. - - Returns the project directory path. - """ - if agent not in VALID_AGENTS: - raise ValueError(f"agent must be one of {VALID_AGENTS}, got '{agent}'") - - project_dir = project_dir_for(project_name, runtime_base) - - if (project_dir / "dsagt_config.yaml").exists(): - raise FileExistsError(f"Project already exists: {project_dir}") - - project_dir.mkdir(parents=True, exist_ok=True) - for subdir in ("trace_archive", "mlflow", "skills", "kb_index"): - (project_dir / subdir).mkdir(exist_ok=True) - - config_content = default_config_content(project_name, agent) - (project_dir / "dsagt_config.yaml").write_text(config_content) - - return project_dir - - -# --------------------------------------------------------------------------- -# Agent config generation -# --------------------------------------------------------------------------- - -def generate_agent_configs(config: dict, working_dir: str | Path) -> list[str]: - """Generate agent-platform-specific config files in the working directory. - - Reads the dsagt_config.yaml values and writes the files each agent - platform expects: MCP server configs, agent instructions, env setup. - - Returns a list of descriptions of what was written. - """ - agent = config["agent"] - working_dir = Path(working_dir) - project_dir = Path(config["project_dir"]) - proxy_port = config["proxy"]["port"] - - generators = { - "claude-code": _generate_claude_code, - "goose": _generate_goose, - "roo": _generate_roo, - "cline": _generate_cline, - } - - return generators[agent](config, working_dir, project_dir, proxy_port) - - -def _mcp_server_args(server: str, project_dir: Path) -> list[str]: - """Build the args list for an MCP server entry.""" - base = ["run", f"dsagt-{server}-server"] - if server == "registry": - base += ["--runtime-dir", str(project_dir)] - elif server == "knowledge": - base += [ - "--base-index-dir", str(project_dir / "kb_index"), - "--runtime-dir", str(project_dir), - ] - return base - - -def _mcp_env_block(config: dict) -> dict: - """Build the env block for MCP server entries.""" - env = {} - embedding_key = config.get("embedding", {}).get("api_key", "") - if embedding_key and not embedding_key.startswith("${"): - env["LLM_API_KEY"] = embedding_key - return env - - -def _generate_claude_code(config, working_dir, project_dir, proxy_port) -> list[str]: - actions = [] - env_block = _mcp_env_block(config) - - mcp_config = {"mcpServers": {}} - for server in ("registry", "knowledge"): - entry = { - "command": "uv", - "args": _mcp_server_args(server, project_dir), - } - if env_block: - entry["env"] = env_block - mcp_config["mcpServers"][f"dsagt-{server}"] = entry - - mcp_path = working_dir / ".mcp.json" - mcp_path.write_text(json.dumps(mcp_config, indent=2) + "\n") - actions.append(f"Wrote {mcp_path}") - - # Copy agent instructions - instructions = _load_instructions("claude-code", "dsagt_instructions.md") - if instructions: - claude_md = working_dir / "CLAUDE.md" - if claude_md.exists(): - existing = claude_md.read_text() - if "DSAGT Pipeline Builder" not in existing: - claude_md.write_text(existing + "\n\n" + instructions) - actions.append(f"Appended DSAGT instructions to {claude_md}") - else: - claude_md.write_text(instructions) - actions.append(f"Wrote {claude_md}") - - # Write env helper - env_path = working_dir / ".dsagt_env" - _write_env_file(env_path, { - "ANTHROPIC_BASE_URL": f"http://localhost:{proxy_port}", - "DSAGT_PROJECT": config["project"], - "DSAGT_PROJECT_DIR": str(project_dir), - }) - actions.append(f"Wrote {env_path} (source this or export the variables)") - - return actions - - -def _generate_goose(config, working_dir, project_dir, proxy_port) -> list[str]: - actions = [] - model = config["llm"]["model"] - - goose_config = { - "GOOSE_PROVIDER": "openai", - "GOOSE_MODEL": model, - "extensions": {}, - } - - for server in ("registry", "knowledge"): - args = _mcp_server_args(server, project_dir) - goose_config["extensions"][server] = { - "enabled": True, - "name": server, - "type": "stdio", - "cmd": "uv " + " ".join(args), - "timeout": 300, - } - - goose_path = working_dir / "goose.yaml" - goose_path.write_text(yaml.dump(goose_config, default_flow_style=False, sort_keys=False)) - actions.append(f"Wrote {goose_path}") - - # Copy agent instructions - instructions = _load_instructions("goose", ".goosehints") - if instructions: - hints_path = working_dir / ".goosehints" - hints_path.write_text(instructions) - actions.append(f"Wrote {hints_path}") - - env_path = working_dir / ".dsagt_env" - _write_env_file(env_path, { - "OPENAI_HOST": f"http://localhost:{proxy_port}", - "DSAGT_PROJECT": config["project"], - "DSAGT_PROJECT_DIR": str(project_dir), - }) - actions.append(f"Wrote {env_path}") - - return actions - - -def _generate_roo(config, working_dir, project_dir, proxy_port) -> list[str]: - actions = [] - env_block = _mcp_env_block(config) - - mcp_config = {"mcpServers": {}} - for server in ("registry", "knowledge"): - entry = { - "command": "uv", - "args": _mcp_server_args(server, project_dir), - "disabled": False, - } - if env_block: - entry["env"] = env_block - mcp_config["mcpServers"][f"dsagt-{server}"] = entry - - roo_dir = working_dir / ".roo" - roo_dir.mkdir(exist_ok=True) - mcp_path = roo_dir / "mcp.json" - mcp_path.write_text(json.dumps(mcp_config, indent=2) + "\n") - actions.append(f"Wrote {mcp_path}") - - # Copy roomodes - roomodes = _load_instructions("roo", "roomodes") - if roomodes: - roomodes_path = working_dir / ".roomodes" - roomodes_path.write_text(roomodes) - actions.append(f"Wrote {roomodes_path}") - - env_path = working_dir / ".dsagt_env" - _write_env_file(env_path, { - "DSAGT_PROJECT": config["project"], - "DSAGT_PROJECT_DIR": str(project_dir), - }) - actions.append(f"Wrote {env_path}") - - return actions - - -def _generate_cline(config, working_dir, project_dir, proxy_port) -> list[str]: - actions = [] - env_block = _mcp_env_block(config) - - mcp_config = {"mcpServers": {}} - for server in ("registry", "knowledge"): - entry = { - "command": "uv", - "args": _mcp_server_args(server, project_dir), - "disabled": False, - "alwaysAllow": [], - } - if env_block: - entry["env"] = env_block - mcp_config["mcpServers"][f"dsagt-{server}"] = entry - - # Write to project dir (user merges into global cline settings) - mcp_path = working_dir / "cline_mcp.json" - mcp_path.write_text(json.dumps(mcp_config, indent=2) + "\n") - actions.append(f"Wrote {mcp_path} (merge into Cline MCP settings)") - - # Copy agent instructions - instructions = _load_instructions("cline", "dsagt_instructions.md") - if instructions: - rules_dir = working_dir / ".clinerules" - rules_dir.mkdir(exist_ok=True) - instr_path = rules_dir / "dsagt_instructions.md" - instr_path.write_text(instructions) - actions.append(f"Wrote {instr_path}") - - env_path = working_dir / ".dsagt_env" - _write_env_file(env_path, { - "DSAGT_PROJECT": config["project"], - "DSAGT_PROJECT_DIR": str(project_dir), - }) - actions.append(f"Wrote {env_path}") - - return actions - - -def _load_instructions(agent_dir: str, filename: str) -> str | None: - """Load an instruction template from the agents directory.""" - path = _AGENTS_DIR / agent_dir / filename - if path.exists(): - return path.read_text() - logger.warning("Instruction template not found: %s", path) - return None - - -def _write_env_file(path: Path, env_vars: dict) -> None: - """Write a sourceable env file.""" - lines = [f'export {k}="{v}"' for k, v in env_vars.items()] - path.write_text("\n".join(lines) + "\n") - - -# --------------------------------------------------------------------------- -# Service start / stop -# --------------------------------------------------------------------------- - -def _pid_file(project_dir: Path) -> Path: - return project_dir / ".pids" - - -def start_services(config: dict) -> dict[str, int]: - """Start the proxy and MLflow for a project. Returns {name: pid}.""" - project_dir = Path(config["project_dir"]) - pids = {} - - # Start MLflow - mlflow_port = config["mlflow"]["port"] - mlflow_backend = config["mlflow"]["backend"] - mlflow_dir = project_dir / "mlflow" - mlflow_dir.mkdir(exist_ok=True) - - if mlflow_backend == "sqlite": - backend_uri = f"sqlite:///{mlflow_dir}/mlflow.db" - else: - backend_uri = str(mlflow_dir) - - mlflow_cmd = [ - sys.executable, "-m", "mlflow", "server", - "--backend-store-uri", backend_uri, - "--default-artifact-root", str(mlflow_dir / "artifacts"), - "--host", "0.0.0.0", - "--port", str(mlflow_port), - ] - - mlflow_log = project_dir / "mlflow.log" - mlflow_proc = subprocess.Popen( - mlflow_cmd, - stdout=open(mlflow_log, "w"), - stderr=subprocess.STDOUT, - start_new_session=True, - ) - pids["mlflow"] = mlflow_proc.pid - logger.info("MLflow started (pid %d) → http://localhost:%d", mlflow_proc.pid, mlflow_port) - - # Start proxy - proxy_port = config["proxy"]["port"] - otel_endpoint = f"http://localhost:{mlflow_port}" - trace_dir = str(project_dir / "trace_archive") - - proxy_cmd = [ - sys.executable, "-m", "dsagt.proxy", - "--port", str(proxy_port), - "--records-dir", trace_dir, - "--session", config["project"], - "--otel-endpoint", otel_endpoint, - "--model", config["llm"]["model"], - ] - - proxy_log = project_dir / "proxy.log" - proxy_proc = subprocess.Popen( - proxy_cmd, - stdout=open(proxy_log, "w"), - stderr=subprocess.STDOUT, - env={**os.environ, "DSAGT_PROJECT": config["project"]}, - start_new_session=True, - ) - pids["proxy"] = proxy_proc.pid - logger.info("Proxy started (pid %d) → http://localhost:%d", proxy_proc.pid, proxy_port) - - # Save PIDs - pid_path = _pid_file(project_dir) - pid_path.write_text(json.dumps(pids, indent=2) + "\n") - - return pids - - -def stop_services(project_dir: str | Path) -> list[str]: - """Stop running services for a project. Returns descriptions of what was stopped.""" - project_dir = Path(project_dir) - pid_path = _pid_file(project_dir) - stopped = [] - - if not pid_path.exists(): - return ["No running services found."] - - pids = json.loads(pid_path.read_text()) - - for name, pid in pids.items(): - try: - os.killpg(os.getpgid(pid), signal.SIGTERM) - stopped.append(f"Stopped {name} (pid {pid})") - except (ProcessLookupError, PermissionError): - stopped.append(f"{name} (pid {pid}) was not running") - - pid_path.unlink(missing_ok=True) - return stopped - - -# --------------------------------------------------------------------------- -# Agent launch -# --------------------------------------------------------------------------- - -def agent_env(config: dict) -> dict: - """Build the environment dict an agent process needs to inherit. - - Merges the current environment with DSAGT-specific variables so the - proxy intercept, project identity, and embedding key are all set. - """ - project_dir = config["project_dir"] - proxy_port = config["proxy"]["port"] - agent = config["agent"] - - env = dict(os.environ) - env["DSAGT_PROJECT"] = config["project"] - env["DSAGT_PROJECT_DIR"] = project_dir - - # Proxy routing — agent-specific env var - if agent in ("claude-code", "roo", "cline"): - env["ANTHROPIC_BASE_URL"] = f"http://localhost:{proxy_port}" - if agent == "goose": - env["OPENAI_HOST"] = f"http://localhost:{proxy_port}" - - # Embedding API key for MCP servers - embedding_key = config.get("embedding", {}).get("api_key", "") - if embedding_key and not embedding_key.startswith("${"): - env["LLM_API_KEY"] = embedding_key - - return env - - -def agent_command(config: dict) -> list[str] | None: - """Return the shell command to launch the agent, or None for VS Code agents.""" - commands = { - "claude-code": ["claude"], - "goose": ["goose", "session"], - } - return commands.get(config["agent"]) - - -def launch_agent(config: dict, working_dir: str | Path) -> int: - """Launch the agent process in the foreground with the correct environment. - - For CLI agents (Claude Code, Goose): runs the agent interactively and - returns its exit code. - - For VS Code agents (Roo, Cline): prints instructions and returns 0. - """ - working_dir = Path(working_dir) - env = agent_env(config) - cmd = agent_command(config) - - if cmd is None: - # VS Code agents can't be launched from the CLI - agent = config["agent"] - print(f"\n Open VS Code in: {working_dir}") - if agent == "roo": - print(" Switch to the DSAGT Pipeline Builder mode (Cmd+.)") - elif agent == "cline": - print(" Open the Cline panel and verify MCP servers are connected") - print() - return 0 - - logger.info("Launching: %s", " ".join(cmd)) - try: - result = subprocess.run(cmd, env=env, cwd=str(working_dir)) - return result.returncode - except FileNotFoundError: - logger.error("Command not found: %s", cmd[0]) - logger.error("Is %s installed?", config["agent"]) - return 1 - except KeyboardInterrupt: - return 0 diff --git a/tests/mcp_helpers.py b/tests/mcp_helpers.py index 62c6a3a..583e414 100644 --- a/tests/mcp_helpers.py +++ b/tests/mcp_helpers.py @@ -151,8 +151,13 @@ def mcp_call_tool(proc, tool_name: str, arguments: dict, return read_mcp_message(proc, timeout=timeout, expect_id=msg_id) -def start_server(cmd: list[str], env: dict = None) -> subprocess.Popen: - """Start a server subprocess with stdio pipes.""" +def start_server(cmd: list[str], env: dict = None, cwd: str = None) -> subprocess.Popen: + """Start a server subprocess with stdio pipes. + + ``cwd`` sets the working directory — ``dsagt-server`` discovers its project + config from cwd (see ``observability.find_project_config``), so startup tests + must run it from the project dir. + """ proc_env = os.environ.copy() if env: proc_env.update(env) @@ -164,4 +169,5 @@ def start_server(cmd: list[str], env: dict = None) -> subprocess.Popen: stderr=subprocess.PIPE, text=True, env=proc_env, + cwd=cwd, ) diff --git a/tests/test_dependency_integration.py b/tests/test_dependency_integration.py index c0ad35b..9db0064 100644 --- a/tests/test_dependency_integration.py +++ b/tests/test_dependency_integration.py @@ -31,7 +31,7 @@ import yaml -from dsagt.commands.registry_server import create_registry_server +from dsagt.mcp.registry_tools import create_registry_server from dsagt.registry import ToolRegistry diff --git a/tests/test_dsagt_server.py b/tests/test_dsagt_server.py index d537e34..3aa3486 100644 --- a/tests/test_dsagt_server.py +++ b/tests/test_dsagt_server.py @@ -1,9 +1,12 @@ -"""Tests for the merged ``dsagt-server`` (registry + knowledge under one Server). - -These verify the *composition* contract: every tool from both concern modules is -exposed under one MCP ``Server``, and the single ``call_tool`` wrapper preserves -both return-type contracts (registry handlers return a plain string; knowledge -handlers return a dict that gets JSON-encoded). +"""Tests for the merged ``dsagt-server`` (all four concern modules under one Server). + +These verify the *composition* contract: every tool from the registry / knowledge +/ memory / skill modules is exposed under one MCP ``Server``, and the single +``call_tool`` wrapper preserves both return-type contracts (registry + skill +handlers may return a plain string; knowledge / memory handlers return a dict +that gets JSON-encoded). Also covers ``_build_kb_from_config`` credential +validation in-process (the full subprocess boot needs a live MLflow — see +``test_server_startup.py``). """ import asyncio @@ -14,7 +17,7 @@ import mcp.types as types import pytest -from dsagt.commands.dsagt_server import create_dsagt_server +from dsagt.mcp.server import _build_kb_from_config, create_dsagt_server from dsagt.registry import SkillRegistry, ToolRegistry @@ -50,7 +53,7 @@ def test_merged_server_exposes_all_tools(tmp_path): """Both concern modules' tools land under one server with no collision.""" server = _make_merged_server(tmp_path) names = _list_tools(server) - # 11 registry + 12 knowledge = 23 distinct tools. + # 8 registry + 6 knowledge + 4 memory + 5 skill = 23 distinct tools. assert len(names) == 23 assert len(set(names)) == len(names) # no name collision # Representative tools from each side. @@ -79,3 +82,35 @@ def test_knowledge_tool_returns_json(tmp_path): out = _call(server, "list_skill_sources", {}) parsed = json.loads(out) assert "sources" in parsed + + +class TestBuildKbFromConfig: + """``_build_kb_from_config`` validates embedding config before building a KB. + + These raise paths fire before any embedder / ChromaDB construction, so they + need no real backend. + """ + + def _cfg(self, **embedding): + return { + "embedding": embedding, + "knowledge": {"chunk_size": 1024, "vector_db": "chroma", "rerank": False}, + } + + def test_invalid_backend_raises(self, tmp_path): + cfg = self._cfg(backend="not-a-backend") + with pytest.raises(ValueError, match="backend must be"): + _build_kb_from_config(cfg, tmp_path) + + def test_api_backend_without_base_url_raises(self, tmp_path): + cfg = self._cfg(backend="api", model="m", base_url="", api_key="k") + with pytest.raises(ValueError, match="requires embedding.base_url"): + _build_kb_from_config(cfg, tmp_path) + + def test_api_backend_without_api_key_raises(self, tmp_path): + # Unresolved ``${...}`` placeholder counts as missing. + cfg = self._cfg( + backend="api", model="m", base_url="http://x", api_key="${LLM_API_KEY}" + ) + with pytest.raises(ValueError, match="requires embedding.api_key"): + _build_kb_from_config(cfg, tmp_path) diff --git a/tests/test_kb_search_filters.py b/tests/test_kb_search_filters.py index 73919ca..23aedb7 100644 --- a/tests/test_kb_search_filters.py +++ b/tests/test_kb_search_filters.py @@ -11,7 +11,7 @@ import pytest import mcp.types as types -from dsagt.commands.knowledge_server import create_knowledge_server +from dsagt.mcp.knowledge_tools import create_knowledge_server from mcp_helpers import call_tool_json as call_tool diff --git a/tests/test_knowledge_integration.py b/tests/test_knowledge_integration.py index 5ff6fe5..ca94a1e 100755 --- a/tests/test_knowledge_integration.py +++ b/tests/test_knowledge_integration.py @@ -23,7 +23,7 @@ pytestmark = pytest.mark.integration from dsagt.knowledge import KnowledgeBase -from dsagt.commands.knowledge_server import create_knowledge_server, setup_runtime_kb +from dsagt.mcp.knowledge_tools import create_knowledge_server, setup_runtime_kb from mcp_helpers import call_tool_json as call_tool, call_tool_async as _call_tool_async_raw diff --git a/tests/test_knowledge_server.py b/tests/test_knowledge_server.py index 49c4cba..3275fbc 100644 --- a/tests/test_knowledge_server.py +++ b/tests/test_knowledge_server.py @@ -18,7 +18,7 @@ import pytest import mcp.types as types -from dsagt.commands.knowledge_server import create_knowledge_server, setup_runtime_kb +from dsagt.mcp.knowledge_tools import create_knowledge_server, setup_runtime_kb from mcp_helpers import call_tool_json as call_tool @@ -110,28 +110,6 @@ def server(mock_kb): # --------------------------------------------------------------------------- -class TestSkillSources: - - def test_list_skill_sources_returns_known(self, mock_kb): - mock_kb.collections = [] - server = create_knowledge_server(mock_kb) - result = call_tool(server, "list_skill_sources", {}) - assert "scientific" in result["sources"] - # Nothing synced → every known source flagged available, not synced. - assert result["sources"]["scientific"]["synced"] is False - assert result["sources"]["scientific"]["indexed"] == 0 - assert result["other_synced_collections"] == [] - assert "scientific" in result["note"] - - def test_add_skill_source_bad_source_errors(self, mock_kb): - mock_kb.collections = [] - server = create_knowledge_server(mock_kb) - result = call_tool( - server, "add_skill_source", {"source": "not-a-real-known-name"} - ) - assert "error" in result - - class TestListCollections: def test_returns_collections(self, server, mock_kb): @@ -753,17 +731,17 @@ def test_does_not_overwrite_existing(self, tmp_path): class TestOpenMPWorkaround: - """Importing knowledge_server must set KMP_DUPLICATE_LIB_OK to prevent - a fatal OpenMP crash when FAISS and sentence-transformers (PyTorch) + """Importing the knowledge tools module must set KMP_DUPLICATE_LIB_OK to + prevent a fatal OpenMP crash when FAISS and sentence-transformers (PyTorch) both bundle libomp. Without this, kb_search with rerank=true kills the server process, producing 'transport closed' in MCP clients.""" def test_kmp_duplicate_lib_ok_is_set(self): - """KMP_DUPLICATE_LIB_OK is set after importing the knowledge server.""" + """KMP_DUPLICATE_LIB_OK is set after importing dsagt.mcp.knowledge_tools.""" import os - import dsagt.commands.knowledge_server # noqa: F401 + import dsagt.mcp.knowledge_tools # noqa: F401 assert os.environ.get("KMP_DUPLICATE_LIB_OK") == "TRUE" @@ -816,3 +794,127 @@ def test_search_omitted_rerank_passes_none(self, mock_kb): top_k=5, rerank=None, ) + + +# --------------------------------------------------------------------------- +# kb_search — multi-collection fan-out (moved from the former memory test file) +# --------------------------------------------------------------------------- + + +class TestKbSearchMultiCollection: + + def test_single_collection_backward_compat(self, server, mock_kb): + """Plain search with collection still works.""" + result = call_tool( + server, + "kb_search", + { + "query": "test", + "collection": "docs", + }, + ) + + assert result["status"] == "ok" + mock_kb.search.assert_called_once() + + def test_multi_collection_fanout(self, server, mock_kb): + """Searching multiple collections calls search for each.""" + mock_kb.search.return_value = [ + make_search_result("result", "/file.md", 0, 0.9), + ] + + result = call_tool( + server, + "kb_search", + { + "query": "test", + "collections": ["docs", "papers"], + }, + ) + + assert result["status"] == "ok" + assert mock_kb.search.call_count == 2 + + def test_no_collection_returns_error(self, server): + result = call_tool( + server, + "kb_search", + { + "query": "test", + }, + ) + + assert result["status"] == "error" + + def test_multi_collection_merges_results(self, server, mock_kb): + """Results from multiple collections are merged and sorted.""" + call_count = [0] + + def varying_results(**kwargs): + call_count[0] += 1 + score = 0.9 if call_count[0] == 1 else 0.7 + return [ + make_search_result( + f"result_{call_count[0]}", + f"/file_{call_count[0]}.md", + score=score, + ) + ] + + mock_kb.search.side_effect = varying_results + + result = call_tool( + server, + "kb_search", + { + "query": "test", + "collections": ["docs", "papers"], + "top_k": 5, + }, + ) + + assert result["result_count"] == 2 + scores = [r["score"] for r in result["results"]] + assert scores == sorted(scores, reverse=True) + + def test_missing_collection_skipped(self, server, mock_kb): + """A missing collection logs a warning but doesn't fail the search.""" + + def search_with_error(**kwargs): + if kwargs["collection"] == "missing": + raise ValueError("Collection 'missing' not found") + return [make_search_result("result", "/file.md")] + + mock_kb.search.side_effect = search_with_error + + result = call_tool( + server, + "kb_search", + { + "query": "test", + "collections": ["docs", "missing"], + }, + ) + + assert result["status"] == "ok" + assert result["result_count"] == 1 + + +class TestKbSearchSchema: + + def _get_tool(self, server, name): + req = types.ListToolsRequest(method="tools/list") + handler = server.request_handlers[types.ListToolsRequest] + result = asyncio.run(handler(req)) + for tool in result.root.tools: + if tool.name == name: + return tool + return None + + def test_kb_search_has_collections_param(self, server): + tool = self._get_tool(server, "kb_search") + assert "collections" in tool.inputSchema["properties"] + + def test_kb_search_query_is_only_required(self, server): + tool = self._get_tool(server, "kb_search") + assert tool.inputSchema["required"] == ["query"] diff --git a/tests/test_knowledge_server_memory.py b/tests/test_knowledge_server_memory.py deleted file mode 100644 index a4d1e9f..0000000 --- a/tests/test_knowledge_server_memory.py +++ /dev/null @@ -1,278 +0,0 @@ -""" -Tests for kb_remember, kb_get_memories, and extended kb_search handlers. - -Drop this file into tests/test_knowledge_server_memory.py - -These tests use a real ExplicitMemory (file-backed, no mocking needed) -and a mocked KnowledgeBase (same pattern as existing server tests). -""" - -import asyncio -from pathlib import Path -from unittest.mock import MagicMock - -import pytest -import mcp.types as types - -from dsagt.commands.knowledge_server import create_knowledge_server -from dsagt.memory import ExplicitMemory -from mcp_helpers import call_tool_json as call_tool - - -def make_search_result(text, source_file, chunk_index=0, score=0.9): - return { - "chunk": { - "text": text, - "metadata": { - "source_file": source_file, - "collection": "test_collection", - "chunk_index": chunk_index, - "file_type": ".md", - }, - }, - "score": score, - } - - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - - -@pytest.fixture -def mock_kb(tmp_path): - kb = MagicMock() - kb.index_dir = tmp_path / "kb_index" - kb.index_dir.mkdir() - kb.list_collections.return_value = [ - {"name": "docs", "description": "Documentation"}, - ] - kb.search.return_value = [ - make_search_result("Result one", "/path/to/file.md", 0, 0.95), - ] - kb.ingest.return_value = {"collection": "docs", "files": 1, "chunks": 10} - kb.append.return_value = {"collection": "docs", "files": 1, "chunks_added": 5, "total_chunks": 15} - return kb - - -@pytest.fixture -def server(mock_kb, tmp_path): - return create_knowledge_server(mock_kb, runtime_dir=tmp_path) - - -@pytest.fixture -def memory(tmp_path): - return ExplicitMemory(runtime_dir=tmp_path) - - -# --------------------------------------------------------------------------- -# kb_remember -# --------------------------------------------------------------------------- - - -class TestKbRemember: - - def test_stores_a_fact(self, server): - result = call_tool(server, "kb_remember", { - "text": "fastp quality threshold is Q20", - }) - - assert result["status"] == "ok" - assert result["entry_id"] - assert result["total_memories"] == 1 - - def test_stores_with_metadata(self, server): - result = call_tool(server, "kb_remember", { - "text": "some fact", - "category": "quality_control", - "session_id": "sess_01", - }) - - assert result["status"] == "ok" - - def test_supersede_existing(self, server): - r1 = call_tool(server, "kb_remember", { - "text": "old threshold Q20", - }) - r2 = call_tool(server, "kb_remember", { - "text": "new threshold Q30", - "supersedes": r1["entry_id"], - }) - - assert r2["status"] == "ok" - assert r2["superseded_id"] == r1["entry_id"] - assert r2["total_memories"] == 1 - - def test_supersede_nonexistent_returns_error(self, server): - result = call_tool(server, "kb_remember", { - "text": "new fact", - "supersedes": "bad_id", - }) - - assert result["status"] == "error" - assert "not found" in result["error"] - - def test_multiple_facts(self, server): - call_tool(server, "kb_remember", {"text": "fact one"}) - call_tool(server, "kb_remember", {"text": "fact two"}) - result = call_tool(server, "kb_remember", {"text": "fact three"}) - - assert result["total_memories"] == 3 - - -# --------------------------------------------------------------------------- -# kb_get_memories -# --------------------------------------------------------------------------- - - -class TestKbGetMemories: - - def test_empty_returns_zero(self, server): - result = call_tool(server, "kb_get_memories", {}) - - assert result["status"] == "ok" - assert result["count"] == 0 - assert result["memories"] == [] - - def test_returns_stored_memories(self, server): - call_tool(server, "kb_remember", {"text": "fact one"}) - call_tool(server, "kb_remember", {"text": "fact two"}) - - result = call_tool(server, "kb_get_memories", {}) - - assert result["count"] == 2 - texts = {m["text"] for m in result["memories"]} - assert texts == {"fact one", "fact two"} - - def test_excludes_superseded(self, server): - r1 = call_tool(server, "kb_remember", {"text": "old fact"}) - call_tool(server, "kb_remember", { - "text": "new fact", - "supersedes": r1["entry_id"], - }) - - result = call_tool(server, "kb_get_memories", {}) - - assert result["count"] == 1 - assert result["memories"][0]["text"] == "new fact" - - -# --------------------------------------------------------------------------- -# kb_search — multi-collection fan-out -# --------------------------------------------------------------------------- - - -class TestKbSearchMultiCollection: - - def test_single_collection_backward_compat(self, server, mock_kb): - """Plain search with collection still works.""" - result = call_tool(server, "kb_search", { - "query": "test", - "collection": "docs", - }) - - assert result["status"] == "ok" - mock_kb.search.assert_called_once() - - def test_multi_collection_fanout(self, server, mock_kb): - """Searching multiple collections calls search for each.""" - mock_kb.search.return_value = [ - make_search_result("result", "/file.md", 0, 0.9), - ] - - result = call_tool(server, "kb_search", { - "query": "test", - "collections": ["docs", "papers"], - }) - - assert result["status"] == "ok" - assert mock_kb.search.call_count == 2 - - def test_no_collection_returns_error(self, server): - result = call_tool(server, "kb_search", { - "query": "test", - }) - - assert result["status"] == "error" - - def test_multi_collection_merges_results(self, server, mock_kb): - """Results from multiple collections are merged and sorted.""" - call_count = [0] - - def varying_results(**kwargs): - call_count[0] += 1 - score = 0.9 if call_count[0] == 1 else 0.7 - return [make_search_result( - f"result_{call_count[0]}", - f"/file_{call_count[0]}.md", - score=score, - )] - - mock_kb.search.side_effect = varying_results - - result = call_tool(server, "kb_search", { - "query": "test", - "collections": ["docs", "papers"], - "top_k": 5, - }) - - assert result["result_count"] == 2 - scores = [r["score"] for r in result["results"]] - assert scores == sorted(scores, reverse=True) - - def test_missing_collection_skipped(self, server, mock_kb): - """A missing collection logs a warning but doesn't fail the search.""" - def search_with_error(**kwargs): - if kwargs["collection"] == "missing": - raise ValueError("Collection 'missing' not found") - return [make_search_result("result", "/file.md")] - - mock_kb.search.side_effect = search_with_error - - result = call_tool(server, "kb_search", { - "query": "test", - "collections": ["docs", "missing"], - }) - - assert result["status"] == "ok" - assert result["result_count"] == 1 - - -# --------------------------------------------------------------------------- -# Tool schemas -# --------------------------------------------------------------------------- - - -class TestToolSchemas: - - def _get_tool(self, server, name): - req = types.ListToolsRequest(method="tools/list") - handler = server.request_handlers[types.ListToolsRequest] - result = asyncio.run(handler(req)) - for tool in result.root.tools: - if tool.name == name: - return tool - return None - - def test_kb_remember_exists(self, server): - tool = self._get_tool(server, "kb_remember") - assert tool is not None - assert "text" in tool.inputSchema["properties"] - assert tool.inputSchema["required"] == ["text"] - - def test_kb_remember_has_optional_params(self, server): - tool = self._get_tool(server, "kb_remember") - for param in ("category", "session_id", "supersedes"): - assert param in tool.inputSchema["properties"] - - def test_kb_get_memories_exists(self, server): - tool = self._get_tool(server, "kb_get_memories") - assert tool is not None - - def test_kb_search_has_collections_param(self, server): - tool = self._get_tool(server, "kb_search") - assert "collections" in tool.inputSchema["properties"] - - def test_kb_search_query_is_only_required(self, server): - tool = self._get_tool(server, "kb_search") - assert tool.inputSchema["required"] == ["query"] diff --git a/tests/test_memory_tools.py b/tests/test_memory_tools.py new file mode 100644 index 0000000..4b05111 --- /dev/null +++ b/tests/test_memory_tools.py @@ -0,0 +1,188 @@ +""" +Tests for the explicit-memory MCP tools (kb_remember, kb_get_memories). + +These tests use a real ExplicitMemory (file-backed, no mocking needed) and a +mocked KnowledgeBase (same pattern as the other server tests). The tools live +in :mod:`dsagt.mcp.memory_tools`; ``create_memory_server`` exposes just that +concern for driving via ``call_tool_sync``. +""" + +import asyncio +from unittest.mock import MagicMock + +import pytest +import mcp.types as types + +from dsagt.mcp.memory_tools import create_memory_server +from dsagt.memory import ExplicitMemory +from mcp_helpers import call_tool_json as call_tool + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_kb(tmp_path): + kb = MagicMock() + kb.index_dir = tmp_path / "kb_index" + kb.index_dir.mkdir() + return kb + + +@pytest.fixture +def server(mock_kb, tmp_path): + return create_memory_server(mock_kb, runtime_dir=tmp_path) + + +@pytest.fixture +def memory(tmp_path): + return ExplicitMemory(runtime_dir=tmp_path) + + +# --------------------------------------------------------------------------- +# kb_remember +# --------------------------------------------------------------------------- + + +class TestKbRemember: + + def test_stores_a_fact(self, server): + result = call_tool( + server, + "kb_remember", + { + "text": "fastp quality threshold is Q20", + }, + ) + + assert result["status"] == "ok" + assert result["entry_id"] + assert result["total_memories"] == 1 + + def test_stores_with_metadata(self, server): + result = call_tool( + server, + "kb_remember", + { + "text": "some fact", + "category": "quality_control", + "session_id": "sess_01", + }, + ) + + assert result["status"] == "ok" + + def test_supersede_existing(self, server): + r1 = call_tool( + server, + "kb_remember", + { + "text": "old threshold Q20", + }, + ) + r2 = call_tool( + server, + "kb_remember", + { + "text": "new threshold Q30", + "supersedes": r1["entry_id"], + }, + ) + + assert r2["status"] == "ok" + assert r2["superseded_id"] == r1["entry_id"] + assert r2["total_memories"] == 1 + + def test_supersede_nonexistent_returns_error(self, server): + result = call_tool( + server, + "kb_remember", + { + "text": "new fact", + "supersedes": "bad_id", + }, + ) + + assert result["status"] == "error" + assert "not found" in result["error"] + + def test_multiple_facts(self, server): + call_tool(server, "kb_remember", {"text": "fact one"}) + call_tool(server, "kb_remember", {"text": "fact two"}) + result = call_tool(server, "kb_remember", {"text": "fact three"}) + + assert result["total_memories"] == 3 + + +# --------------------------------------------------------------------------- +# kb_get_memories +# --------------------------------------------------------------------------- + + +class TestKbGetMemories: + + def test_empty_returns_zero(self, server): + result = call_tool(server, "kb_get_memories", {}) + + assert result["status"] == "ok" + assert result["count"] == 0 + assert result["memories"] == [] + + def test_returns_stored_memories(self, server): + call_tool(server, "kb_remember", {"text": "fact one"}) + call_tool(server, "kb_remember", {"text": "fact two"}) + + result = call_tool(server, "kb_get_memories", {}) + + assert result["count"] == 2 + texts = {m["text"] for m in result["memories"]} + assert texts == {"fact one", "fact two"} + + def test_excludes_superseded(self, server): + r1 = call_tool(server, "kb_remember", {"text": "old fact"}) + call_tool( + server, + "kb_remember", + { + "text": "new fact", + "supersedes": r1["entry_id"], + }, + ) + + result = call_tool(server, "kb_get_memories", {}) + + assert result["count"] == 1 + assert result["memories"][0]["text"] == "new fact" + + +# --------------------------------------------------------------------------- +# Tool schemas +# --------------------------------------------------------------------------- + + +class TestToolSchemas: + + def _get_tool(self, server, name): + req = types.ListToolsRequest(method="tools/list") + handler = server.request_handlers[types.ListToolsRequest] + result = asyncio.run(handler(req)) + for tool in result.root.tools: + if tool.name == name: + return tool + return None + + def test_kb_remember_exists(self, server): + tool = self._get_tool(server, "kb_remember") + assert tool is not None + assert "text" in tool.inputSchema["properties"] + assert tool.inputSchema["required"] == ["text"] + + def test_kb_remember_has_optional_params(self, server): + tool = self._get_tool(server, "kb_remember") + for param in ("category", "session_id", "supersedes"): + assert param in tool.inputSchema["properties"] + + def test_kb_get_memories_exists(self, server): + tool = self._get_tool(server, "kb_get_memories") + assert tool is not None diff --git a/tests/test_observability.py b/tests/test_observability.py index dd1ff46..f29d11e 100644 --- a/tests/test_observability.py +++ b/tests/test_observability.py @@ -735,7 +735,7 @@ def _make_registry_server(tmp_path): Mirrors the pattern used by tests/test_registry_server.py so we exercise the real call_tool dispatcher rather than reaching into private state. """ - from dsagt.commands.registry_server import create_registry_server + from dsagt.mcp.registry_tools import create_registry_server from dsagt.registry import ToolRegistry source_dir = tmp_path / "source_skills" @@ -788,7 +788,7 @@ def test_save_tool_spec_with_deps_nests_install_span( from mcp_helpers import call_tool_sync as call_tool # Stub the actual uv install so the test doesn't hit the network. - import dsagt.commands.registry_server as rs_mod + import dsagt.mcp.registry_tools as rs_mod monkeypatch.setattr( rs_mod, @@ -819,7 +819,7 @@ def test_install_dependencies_failed_records_event( an install_failed event with the error message truncated.""" from mcp_helpers import call_tool_sync as call_tool - import dsagt.commands.registry_server as rs_mod + import dsagt.mcp.registry_tools as rs_mod monkeypatch.setattr( rs_mod, diff --git a/tests/test_registry_server.py b/tests/test_registry_server.py index 461187e..dde1ba9 100644 --- a/tests/test_registry_server.py +++ b/tests/test_registry_server.py @@ -5,7 +5,6 @@ read_file, run_command, install_dependencies. """ -import json import subprocess import sys from pathlib import Path @@ -14,8 +13,8 @@ import pytest import yaml -from dsagt.registry import SkillRegistry, ToolRegistry -from dsagt.commands.registry_server import create_registry_server +from dsagt.registry import ToolRegistry +from dsagt.mcp.registry_tools import create_registry_server from mcp_helpers import call_tool_sync as call_tool @@ -175,113 +174,6 @@ def test_rejects_invalid_stringified_spec(self, server, registry): # --------------------------------------------------------------------------- -class TestInstallSkill: - - def test_install_skill_routes_and_reports_missing(self, server): - """install_skill is registered and reports a clean error when the - named skill isn't in any synced catalog.""" - text = call_tool( - server, - "install_skill", - {"skill_name": "zzz-definitely-not-a-real-skill-xyz"}, - ) - assert "No catalog skill" in text - - -# --------------------------------------------------------------------------- -# save_skill -# --------------------------------------------------------------------------- - - -class TestSaveSkill: - - def test_add_new_skill_creates_files_and_indexes(self, tmp_path): - """save_skill writes SKILL.md and indexes when KB is configured. - - The skill count includes any bundled skills that ship in the - package (see SkillRegistry.list_skills which merges bundled + - project layers), so we assert the file was created and the - count went up by one rather than equality on a specific number. - """ - server, reg, kb = _make_server_with_kb(tmp_path) - from dsagt.registry import SkillRegistry as _SR - - skill_reg = _SR(runtime_dir=str(tmp_path / "runtime"), kb=kb) - before = len(skill_reg.list_skills()) - - spec = { - "name": "csv_inspector", - "description": "Workflow for inspecting CSV columns and quality", - "tags": ["data_management", "quality_control"], - } - body = "# csv_inspector\n\nFirst, run head on the file. Then check nulls.\n" - text = call_tool(server, "save_skill", {"spec": spec, "body": body}) - - assert "added" in text - skill_md = tmp_path / "runtime" / "skills" / "csv_inspector" / "SKILL.md" - assert skill_md.exists() - content = skill_md.read_text() - assert "csv_inspector" in content - assert "First, run head" in content - after = len(skill_reg.list_skills()) - assert after == before + 1 - - def test_update_existing_skill_preserves_body_when_omitted(self, tmp_path): - """Saving a spec for an existing skill without body keeps the body.""" - server, reg, kb = _make_server_with_kb(tmp_path) - first_body = "# orig\n\nOriginal workflow body.\n" - call_tool( - server, - "save_skill", - { - "spec": {"name": "wf", "description": "v1"}, - "body": first_body, - }, - ) - # Update the description only — body should be preserved. - text = call_tool( - server, - "save_skill", - { - "spec": {"name": "wf", "description": "v2 description"}, - }, - ) - assert "updated" in text - skill_md = tmp_path / "runtime" / "skills" / "wf" / "SKILL.md" - content = skill_md.read_text() - assert "v2 description" in content - assert "Original workflow body" in content - - def test_save_skill_writes_reference_files(self, tmp_path): - """reference_files dict lands as additional files in the skill dir.""" - server, reg, kb = _make_server_with_kb(tmp_path) - text = call_tool( - server, - "save_skill", - { - "spec": {"name": "with_template", "description": "Has a template"}, - "body": "# with_template\n\nUses template.json.\n", - "reference_files": {"template.json": '{"foo": "bar"}\n'}, - }, - ) - assert "added" in text - skill_dir = tmp_path / "runtime" / "skills" / "with_template" - assert (skill_dir / "SKILL.md").exists() - assert (skill_dir / "template.json").read_text() == '{"foo": "bar"}\n' - - def test_save_skill_string_encoded_spec(self, tmp_path): - """MCP clients that JSON-encode nested object args still work.""" - server, reg, kb = _make_server_with_kb(tmp_path) - spec_json = json.dumps({"name": "s1", "description": "d"}) - text = call_tool(server, "save_skill", {"spec": spec_json, "body": "x"}) - assert "added" in text - - -# --------------------------------------------------------------------------- -# get_registry -# --------------------------------------------------------------------------- - - class TestGetRegistry: def test_empty_registry(self, server): @@ -416,7 +308,7 @@ def test_timeout(self, server): class TestSaveToolSpecDependencies: - @patch("dsagt.commands.registry_server.subprocess.run") + @patch("dsagt.mcp.registry_tools.subprocess.run") def test_deps_installed_on_save(self, mock_run, server, registry): """When dependencies are provided, uv pip install is called.""" mock_run.return_value = MagicMock( @@ -439,7 +331,7 @@ def test_deps_installed_on_save(self, mock_run, server, registry): "numpy", ] - @patch("dsagt.commands.registry_server.subprocess.run") + @patch("dsagt.mcp.registry_tools.subprocess.run") def test_deps_failure_still_saves_spec(self, mock_run, server, registry): """Even if uv pip install fails, the spec is saved as a skill file.""" mock_run.return_value = MagicMock( @@ -454,7 +346,7 @@ def test_deps_failure_still_saves_spec(self, mock_run, server, registry): assert tool is not None assert tool["dependencies"] == ["bogus-pkg"] - @patch("dsagt.commands.registry_server.subprocess.run") + @patch("dsagt.mcp.registry_tools.subprocess.run") def test_deps_timeout(self, mock_run, server): """Timeout during install is reported, spec is still saved.""" mock_run.side_effect = subprocess.TimeoutExpired("uv", 120) @@ -472,7 +364,7 @@ def test_no_deps_no_install_message(self, server, registry): assert "added" in text assert "Dependency" not in text - @patch("dsagt.commands.registry_server.subprocess.run") + @patch("dsagt.mcp.registry_tools.subprocess.run") def test_deps_persisted_in_skill_file(self, mock_run, server, registry): """Dependencies are stored in the skill file frontmatter.""" mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="") @@ -482,7 +374,7 @@ def test_deps_persisted_in_skill_file(self, mock_run, server, registry): tool = registry.get_tool("dep_tool") assert tool["dependencies"] == ["requests>=2.28"] - @patch("dsagt.commands.registry_server.subprocess.run") + @patch("dsagt.mcp.registry_tools.subprocess.run") def test_uv_not_found(self, mock_run, server): """FileNotFoundError from missing uv is reported gracefully.""" mock_run.side_effect = FileNotFoundError("uv") @@ -500,7 +392,7 @@ def test_uv_not_found(self, mock_run, server): class TestInstallDependencies: - @patch("dsagt.commands.registry_server.subprocess.run") + @patch("dsagt.mcp.registry_tools.subprocess.run") def test_install_all(self, mock_run, tmp_path): """install_dependencies with no tool_name installs all unique deps.""" server, reg = _make_server( @@ -528,7 +420,7 @@ def test_install_all(self, mock_run, tmp_path): "scipy", ] - @patch("dsagt.commands.registry_server.subprocess.run") + @patch("dsagt.mcp.registry_tools.subprocess.run") def test_install_single_tool(self, mock_run, tmp_path): """install_dependencies with tool_name targets only that tool.""" server, reg = _make_server( @@ -591,12 +483,7 @@ def _make_server_with_kb(tmp_path, tools=None): runtime_dir=str(runtime_dir), kb=kb, ) - skill_reg = SkillRegistry( - source_skills_dir=None, # use package default (empty bundled is fine) - runtime_dir=str(runtime_dir), - kb=kb, - ) - server = create_registry_server(reg, kb, skill_reg) + server = create_registry_server(reg, kb) return server, reg, kb @@ -624,16 +511,6 @@ def test_save_tool_indexes_into_kb(self, tmp_path): assert len(results) > 0 assert any("csv_filter" in r["chunk"].get("text", "") for r in results) - def test_search_skills_empty_catalog_hints_to_sync(self, tmp_path): - """With no catalog synced, search_skills explains how to enable one - instead of returning a bare 'no match' the agent reads as exhausted.""" - server, reg, kb = _make_server_with_kb(tmp_path) - - text = call_tool(server, "search_skills", {"query": "vasp pymatgen dft"}) - assert "No catalog skills found" in text - assert "no external skill catalog is synced" in text.lower() - assert "add_skill_source" in text - def test_search_registry_by_name(self, tmp_path): """Exact tool_name lookup returns the tool.""" server, reg, kb = _make_server_with_kb(tmp_path) diff --git a/tests/test_server_startup.py b/tests/test_server_startup.py index fdf5fd4..6d3960e 100644 --- a/tests/test_server_startup.py +++ b/tests/test_server_startup.py @@ -1,160 +1,54 @@ """ -Process-level MCP server startup tests. +Process-level ``dsagt-server`` entry-point tests. -Each server is spawned as a subprocess and must complete the MCP handshake -without an API key or network access. End-to-end ingest/search flows -belong in smoke_test (which drives them through the real agent), not here. +The single merged server (``dsagt.mcp.server:main``) is spawned as a subprocess +to verify the entry point is wired and fails fast + clearly on a misconfigured +project — without needing a live MLflow backend or network access. + +The full boot (init_tracing → shared KB → 23-tool MCP handshake) requires a +running MLflow server, so it is exercised by ``dsagt smoke-test`` (real agent), +not here. The 23-tool composition + dispatch contract is unit-tested in-process +by ``test_dsagt_server.py``; ``_build_kb_from_config``'s credential validation by +``test_dsagt_server.py::TestBuildKbFromConfig``. """ -import os import shutil import sys -from pathlib import Path import pytest -from mcp_helpers import mcp_initialize, mcp_list_tools, start_server - - -# --------------------------------------------------------------------------- -# Skip conditions -# --------------------------------------------------------------------------- +from mcp_helpers import start_server _uv_available = shutil.which("uv") is not None pytestmark = pytest.mark.skipif(not _uv_available, reason="uv not available") - -def _write_minimal_config( - project_dir: Path, - project_name: str = "test", - backend: str = "api", -) -> None: - """Write the minimum dsagt_config.yaml the servers need to start. - - Default backend is ``"api"`` here (with fake credentials) so startup - doesn't trigger a sentence-transformers model load — keeps these - tests fast. Tests that need to exercise the local backend pass - ``backend="local"`` explicitly. - """ - import yaml - config = { - "project": project_name, - "agent": "claude", - "embedding": { - "backend": backend, - "model": "test-model", - "base_url": "http://localhost:9999", - "api_key": "test-fake-key", - }, - "knowledge": { - "chunk_size": 1024, - "vector_db": "chroma", - "rerank": False, - }, - } - (project_dir / "dsagt_config.yaml").write_text( - yaml.dump(config, default_flow_style=False) - ) +_SERVER_CMD = [sys.executable, "-m", "dsagt.mcp.server"] -# --------------------------------------------------------------------------- -# Startup tests (no API key needed) -# --------------------------------------------------------------------------- - -class TestRegistryServerStartup: - - def test_starts_and_lists_tools(self, tmp_path): - """Registry server starts without embedding credentials (kb=None) - and completes the MCP handshake.""" - import yaml as _yaml - project = tmp_path / "runtime" - project.mkdir() - # backend="api" with empty credentials → kb_available=False → - # registry runs with kb=None. This is the fast path the test - # cares about (no ChromaDB / sentence-transformers init). With - # backend="local" (the default for new projects) registry would - # eagerly load the local embedder, which is the slow path - # exercised by other tests. - (project / "dsagt_config.yaml").write_text(_yaml.dump({ - "project": "test", - "agent": "claude", - "embedding": { - "backend": "api", - "model": "", "base_url": "", "api_key": "", - }, - "knowledge": {"chunk_size": 1024, "vector_db": "chroma", "rerank": False}, - })) - proc = start_server( - [sys.executable, "-m", "dsagt.commands.registry_server"], - env={ - "DSAGT_PROJECT_DIR": str(project), - # init_tracing raises without a backend; give it a local - # file store so the handshake proceeds without needing an - # MLflow server. - "MLFLOW_TRACKING_URI": f"file://{tmp_path}/mlruns", - }, - ) - try: - resp = mcp_initialize(proc) - assert "result" in resp, f"Init failed: {resp}" +class TestServerEntryPoint: - tools_resp = mcp_list_tools(proc) - assert "result" in tools_resp, f"list_tools failed: {tools_resp}" + def test_fails_fast_without_project_config(self, tmp_path): + """Run from a dir with no dsagt_config.yaml → clean fail-fast. - tool_names = [t["name"] for t in tools_resp["result"]["tools"]] - assert "save_tool_spec" in tool_names - assert "install_dependencies" in tool_names - finally: - proc.terminate() - proc.wait(timeout=5) - - -class TestKnowledgeServerStartup: - - def test_starts_and_lists_tools(self, tmp_path): - """Knowledge server starts, completes MCP handshake, and lists tools. - - Uses a fake API key — server accepts it at init time since it only - validates the key is non-empty. Actual API calls would fail, but we - don't make any here. + ``dsagt-server`` discovers its project from cwd; launched anywhere else + it must say so rather than boot a half-configured server. """ - project = tmp_path / "runtime" - project.mkdir() - _write_minimal_config(project) - proc = start_server( - [sys.executable, "-m", "dsagt.commands.knowledge_server"], - env={ - "DSAGT_PROJECT_DIR": str(project), - "LLM_API_KEY": "test-fake-key-for-startup", - "MLFLOW_TRACKING_URI": f"file://{tmp_path}/mlruns", - }, - ) - try: - resp = mcp_initialize(proc) - assert "result" in resp, f"Init failed: {resp}" - - tools_resp = mcp_list_tools(proc) - assert "result" in tools_resp, f"list_tools failed: {tools_resp}" - - tool_names = [t["name"] for t in tools_resp["result"]["tools"]] - assert "kb_search" in tool_names - assert "kb_list_collections" in tool_names - assert "kb_ingest" in tool_names - finally: - proc.terminate() - proc.wait(timeout=5) - - def test_api_backend_fails_fast_without_api_key(self, tmp_path): - """When the user explicitly opts into backend='api', the knowledge - server must fail at startup if the api key is missing — better than - booting with a broken embedder that 401s on the first kb_search. - - With the post-refactor default of backend='local' (no creds - needed), this hard-fail only fires for users who have explicitly - said they want the api backend. + empty = tmp_path / "empty" + empty.mkdir() + proc = start_server(_SERVER_CMD, cwd=str(empty)) + rc = proc.wait(timeout=15) + assert rc != 0 + assert "no dsagt_config.yaml in cwd" in proc.stderr.read() + + def test_fails_fast_without_observability_backend(self, tmp_path): + """A project config lacking ``mlflow.port`` → init_tracing fails fast. + + The merged server requires an observability backend (it autologs every + LLM call into MLflow); booting without one is a misconfiguration. """ import yaml + project = tmp_path / "runtime" project.mkdir() config = { @@ -164,22 +58,15 @@ def test_api_backend_fails_fast_without_api_key(self, tmp_path): "backend": "api", "model": "test-model", "base_url": "http://localhost:9999", - "api_key": "${LLM_API_KEY}", # unresolved placeholder + "api_key": "test-fake-key", }, "knowledge": {"chunk_size": 1024, "vector_db": "chroma", "rerank": False}, + # no mlflow.port } (project / "dsagt_config.yaml").write_text( yaml.dump(config, default_flow_style=False) ) - - stripped = {"LLM_API_KEY", "OPENAI_API_KEY"} - clean_env = {k: v for k, v in os.environ.items() if k not in stripped} - clean_env["DSAGT_PROJECT_DIR"] = str(project) - clean_env["MLFLOW_TRACKING_URI"] = f"file://{tmp_path}/mlruns" - - proc = start_server( - [sys.executable, "-m", "dsagt.commands.knowledge_server"], - env=clean_env, - ) - rc = proc.wait(timeout=10) - assert rc != 0, "backend='api' without api_key must fail at startup" + proc = start_server(_SERVER_CMD, cwd=str(project)) + rc = proc.wait(timeout=15) + assert rc != 0 + assert "no observability backend configured" in proc.stderr.read() diff --git a/tests/test_skill_discovery.py b/tests/test_skill_discovery.py index 704d973..489bcf5 100644 --- a/tests/test_skill_discovery.py +++ b/tests/test_skill_discovery.py @@ -7,9 +7,8 @@ import json -from dsagt import skill_keyword from dsagt.registry import SkillRegistry -from dsagt.skill_discovery import SkillRouter +from dsagt.skills import SkillRouter, rank_skills, score_skill def _mkskill(d, name, desc): @@ -62,40 +61,40 @@ def _hit(name, text, source, score, tags=""): def test_score_name_token_weighs_more_than_description(): - name_hit = skill_keyword.score_skill("slurm", "slurm-submit", "unrelated text") - desc_hit = skill_keyword.score_skill("slurm", "other", "submit a slurm job") + name_hit = score_skill("slurm", "slurm-submit", "unrelated text") + desc_hit = score_skill("slurm", "other", "submit a slurm job") assert name_hit > desc_hit def test_score_exact_name_beats_substring(): - exact = skill_keyword.score_skill("datacard", "datacard", "x") - substr = skill_keyword.score_skill("datacard", "datacard-generator", "x") + exact = score_skill("datacard", "datacard", "x") + substr = score_skill("datacard", "datacard-generator", "x") assert exact > substr > 0 def test_score_substring_description_bonus(): - assert skill_keyword.score_skill("batch job", "x", "submit a batch job") > 0 + assert score_skill("batch job", "x", "submit a batch job") > 0 def test_stopwords_do_not_score(): # only stopwords overlap → no score - assert skill_keyword.score_skill("the and of", "the skill", "and of the") == 0.0 + assert score_skill("the and of", "the skill", "and of the") == 0.0 def test_empty_query_scores_zero(): - assert skill_keyword.score_skill("", "anything", "anything") == 0.0 + assert score_skill("", "anything", "anything") == 0.0 def test_substring_bonuses_are_mutually_exclusive(): # Genesis parity: at most ONE of the +6/+4/+2 substring bonuses fires. # name-token 2 + desc-token 1 + exact-name 6 = 9; the desc-substring +2 is # NOT also added (a stacking bug would give 11). - assert skill_keyword.score_skill("alpha", "alpha", "alpha tool") == 9.0 + assert score_skill("alpha", "alpha", "alpha tool") == 9.0 def test_single_char_tokens_dropped(): # "x" is a single char → not a token, so no name-token overlap. - assert skill_keyword.score_skill("x", "x", "y") == 6.0 # only the exact-name bonus + assert score_skill("x", "x", "y") == 6.0 # only the exact-name bonus def test_rank_orders_and_breaks_ties_by_name(): @@ -104,7 +103,7 @@ def test_rank_orders_and_breaks_ties_by_name(): {"name": "alpha", "description": "submit jobs"}, {"name": "unrelated", "description": "nothing here"}, ] - ranked = skill_keyword.rank_skills("submit", skills, top_k=5) + ranked = rank_skills("submit", skills, top_k=5) names = [s["name"] for s, _ in ranked] assert names == ["alpha", "zeta"] # equal score → name asc; unrelated dropped @@ -209,7 +208,7 @@ def test_search_kb_tag_filter(tmp_path): def test_list_sources_flags_synced(tmp_path): - from dsagt.commands.skills_catalog import KNOWN_SOURCES, _repo_slug + from dsagt.skills import KNOWN_SOURCES, _repo_slug from dsagt.registry import catalog_collection genesis_coll = catalog_collection(_repo_slug(KNOWN_SOURCES["genesis"]["url"])) diff --git a/tests/test_skill_tools.py b/tests/test_skill_tools.py new file mode 100644 index 0000000..76485a3 --- /dev/null +++ b/tests/test_skill_tools.py @@ -0,0 +1,200 @@ +""" +Tests for the skill MCP tools (save_skill, search_skills, install_skill, +add_skill_source, list_skill_sources). + +The skill surface lives in :mod:`dsagt.mcp.skill_tools`; ``create_skill_server`` +exposes just that concern for driving via the MCP helpers. Handlers return a +mix of ``str`` (save/search/install) and ``dict`` (add/list sources), so the two +``call_tool`` helpers are both used. +""" + +import json +from unittest.mock import MagicMock + +import pytest + +from dsagt.mcp.skill_tools import create_skill_server +from dsagt.registry import SkillRegistry +from mcp_helpers import call_tool_json, call_tool_sync + + +def _make_skill_server(tmp_path): + """Create (server, skill_registry, kb) with a real local-embedding KB. + + The skill registry is rooted at ``/runtime`` so save_skill writes to + ``/runtime/skills//`` — the project layer the agent natively + discovers. + """ + from dsagt.knowledge import KnowledgeBase + + runtime_dir = tmp_path / "runtime" + runtime_dir.mkdir(parents=True, exist_ok=True) + kb = KnowledgeBase( + index_dir=tmp_path / "kb_index", + default_embedder="local", + default_index="chroma", + ) + skill_reg = SkillRegistry( + source_skills_dir=None, # package default (empty bundled is fine) + runtime_dir=str(runtime_dir), + kb=kb, + ) + server = create_skill_server(skill_reg, kb, runtime_dir=str(runtime_dir)) + return server, skill_reg, kb + + +# --------------------------------------------------------------------------- +# save_skill +# --------------------------------------------------------------------------- + + +class TestSaveSkill: + + def test_add_new_skill_creates_files_and_indexes(self, tmp_path): + """save_skill writes SKILL.md and the skill count goes up by one. + + The count includes any bundled skills that ship in the package (see + SkillRegistry.list_skills which merges bundled + project layers), so we + assert the file was created and the count incremented rather than + equality on a specific number. + """ + server, skill_reg, kb = _make_skill_server(tmp_path) + before = len(skill_reg.list_skills()) + + spec = { + "name": "csv_inspector", + "description": "Workflow for inspecting CSV columns and quality", + "tags": ["data_management", "quality_control"], + } + body = "# csv_inspector\n\nFirst, run head on the file. Then check nulls.\n" + text = call_tool_sync(server, "save_skill", {"spec": spec, "body": body}) + + assert "added" in text + skill_md = tmp_path / "runtime" / "skills" / "csv_inspector" / "SKILL.md" + assert skill_md.exists() + content = skill_md.read_text() + assert "csv_inspector" in content + assert "First, run head" in content + after = len(skill_reg.list_skills()) + assert after == before + 1 + + def test_update_existing_skill_preserves_body_when_omitted(self, tmp_path): + """Saving a spec for an existing skill without body keeps the body.""" + server, skill_reg, kb = _make_skill_server(tmp_path) + first_body = "# orig\n\nOriginal workflow body.\n" + call_tool_sync( + server, + "save_skill", + { + "spec": {"name": "wf", "description": "v1"}, + "body": first_body, + }, + ) + # Update the description only — body should be preserved. + text = call_tool_sync( + server, + "save_skill", + { + "spec": {"name": "wf", "description": "v2 description"}, + }, + ) + assert "updated" in text + skill_md = tmp_path / "runtime" / "skills" / "wf" / "SKILL.md" + content = skill_md.read_text() + assert "v2 description" in content + assert "Original workflow body" in content + + def test_save_skill_writes_reference_files(self, tmp_path): + """reference_files dict lands as additional files in the skill dir.""" + server, skill_reg, kb = _make_skill_server(tmp_path) + text = call_tool_sync( + server, + "save_skill", + { + "spec": {"name": "with_template", "description": "Has a template"}, + "body": "# with_template\n\nUses template.json.\n", + "reference_files": {"template.json": '{"foo": "bar"}\n'}, + }, + ) + assert "added" in text + skill_dir = tmp_path / "runtime" / "skills" / "with_template" + assert (skill_dir / "SKILL.md").exists() + assert (skill_dir / "template.json").read_text() == '{"foo": "bar"}\n' + + def test_save_skill_string_encoded_spec(self, tmp_path): + """MCP clients that JSON-encode nested object args still work.""" + server, skill_reg, kb = _make_skill_server(tmp_path) + spec_json = json.dumps({"name": "s1", "description": "d"}) + text = call_tool_sync(server, "save_skill", {"spec": spec_json, "body": "x"}) + assert "added" in text + + +# --------------------------------------------------------------------------- +# search_skills +# --------------------------------------------------------------------------- + + +class TestSearchSkills: + + def test_search_skills_empty_catalog_hints_to_sync(self, tmp_path): + """With no catalog synced, search_skills explains how to enable one + instead of returning a bare 'no match' the agent reads as exhausted.""" + server, skill_reg, kb = _make_skill_server(tmp_path) + + text = call_tool_sync(server, "search_skills", {"query": "vasp pymatgen dft"}) + assert "No catalog skills found" in text + assert "no external skill catalog is synced" in text.lower() + assert "add_skill_source" in text + + +# --------------------------------------------------------------------------- +# install_skill +# --------------------------------------------------------------------------- + + +class TestInstallSkill: + + def test_install_skill_routes_and_reports_missing(self, tmp_path): + """install_skill is registered and reports a clean error when the + named skill isn't in any synced catalog.""" + server, skill_reg, kb = _make_skill_server(tmp_path) + text = call_tool_sync( + server, + "install_skill", + {"skill_name": "zzz-definitely-not-a-real-skill-xyz"}, + ) + assert "No catalog skill" in text + + +# --------------------------------------------------------------------------- +# skill sources (add_skill_source / list_skill_sources) +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_kb(tmp_path): + kb = MagicMock() + kb.index_dir = tmp_path / "kb_index" + kb.index_dir.mkdir() + kb.collections = [] + return kb + + +class TestSkillSources: + + def test_list_skill_sources_returns_known(self, mock_kb): + server = create_skill_server(kb=mock_kb) + result = call_tool_json(server, "list_skill_sources", {}) + assert "scientific" in result["sources"] + # Nothing synced → every known source flagged available, not synced. + assert result["sources"]["scientific"]["synced"] is False + assert result["sources"]["scientific"]["indexed"] == 0 + assert result["other_synced_collections"] == [] + assert "scientific" in result["note"] + + def test_add_skill_source_bad_source_errors(self, mock_kb): + server = create_skill_server(kb=mock_kb) + result = call_tool_json( + server, "add_skill_source", {"source": "not-a-real-known-name"} + ) + assert "error" in result diff --git a/tests/test_skills_catalog.py b/tests/test_skills_catalog.py index 883c337..7586e59 100644 --- a/tests/test_skills_catalog.py +++ b/tests/test_skills_catalog.py @@ -11,7 +11,7 @@ _SKILL_MANIFEST, _mirror_skills_to, ) -from dsagt.commands import skills_catalog as sc +from dsagt import skills as sc from dsagt.registry import CATALOG_COLLECTION_PREFIX, catalog_collection From c30ba807dcdd640f15cd1f7c38e129877b172aed Mon Sep 17 00:00:00 2001 From: aarontuor Date: Wed, 24 Jun 2026 13:30:28 -0700 Subject: [PATCH 15/44] fix(skills): recover catalog skills with technically-invalid YAML frontmatter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third-party catalogs (e.g. Genesis) ship SKILL.md files whose unquoted `description` contains a colon (`…readiness levels: Level 1…`), which strict YAML rejects — so _parse_frontmatter raised and the skill was silently dropped from indexing, search, and install. Genesis's `generating-datacards` (datacard-generator) was one such casualty. _parse_frontmatter now falls back to a lenient flat key:value parse on YAML error, recovering name/description/tags. dsagt-authored specs are valid YAML, so the fallback never fires for them. Adds TestLenientFrontmatter. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/dsagt/registry.py | 60 +++++++++++++++++++++++- tests/test_registry.py | 103 ++++++++++++++++++++++++++++++++++------- 2 files changed, 145 insertions(+), 18 deletions(-) diff --git a/src/dsagt/registry.py b/src/dsagt/registry.py index 68d7d48..967a7c9 100644 --- a/src/dsagt/registry.py +++ b/src/dsagt/registry.py @@ -102,7 +102,17 @@ def _generate_tool_body(spec: dict) -> str: def _parse_frontmatter(path: Path) -> dict: - """Parse YAML frontmatter from a markdown file.""" + """Parse YAML frontmatter from a markdown file. + + Third-party skill catalogs (e.g. Genesis) ship SKILL.md files whose + frontmatter is *intended* as flat ``key: value`` but isn't strict YAML — + most commonly an unquoted ``description`` value that contains a colon + (``...readiness levels: Level 1...``), which PyYAML rejects as a nested + mapping. Rather than silently dropping such skills from discovery, fall back + to a best-effort flat parse (:func:`_lenient_frontmatter`) on YAML error so + ``name`` / ``description`` / ``tags`` are still recovered. dsagt-authored + tool/skill specs are valid YAML, so the fallback never fires for them. + """ text = path.read_text() if not text.startswith("---"): return {} @@ -112,7 +122,53 @@ def _parse_frontmatter(path: Path) -> dict: try: return yaml.safe_load(parts[1]) or {} except yaml.YAMLError as e: - raise ValueError(f"Invalid YAML frontmatter in {path}: {e}") from e + logger.warning( + "Frontmatter in %s isn't strict YAML (%s); recovering flat fields.", + path, + str(e).splitlines()[0], + ) + return _lenient_frontmatter(parts[1]) + + +def _lenient_frontmatter(block: str) -> dict: + """Best-effort flat ``key: value`` parse for frontmatter that isn't strict YAML. + + Splits each top-level line on its **first** colon (so a value may itself + contain colons); indented ``- item`` lines extend the previous key into a + list, other indented lines continue the previous string value. Inline + ``[...]`` / ``{...}`` values are parsed as YAML when they can be. Lines + without a colon, and comments, are ignored. This recovers the discovery + fields (name/description/tags) from technically-invalid-but-obvious + frontmatter instead of dropping the skill. + """ + out: dict = {} + key: str | None = None + for raw in block.splitlines(): + stripped = raw.strip() + if not stripped or stripped.startswith("#"): + continue + if raw[:1].isspace() and key is not None: + # Continuation of the previous key. + if stripped.startswith("- "): + if not isinstance(out.get(key), list): + out[key] = [] + out[key].append(stripped[2:].strip()) + elif isinstance(out.get(key), str): + out[key] = (out[key] + " " + stripped).strip() + continue + if ":" not in stripped: + continue + k, _, v = stripped.partition(":") + key = k.strip() + v = v.strip() + if v.startswith(("[", "{")): + try: + out[key] = yaml.safe_load(v) + except yaml.YAMLError: + out[key] = v + else: + out[key] = v + return out # --------------------------------------------------------------------------- diff --git a/tests/test_registry.py b/tests/test_registry.py index 2b1c74f..5d5f155 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -9,11 +9,53 @@ import yaml from dsagt.registry import ( - ToolRegistry, SkillRegistry, _wrap_executable, _uv_run_prefix, _parse_frontmatter, + ToolRegistry, + SkillRegistry, + _wrap_executable, + _uv_run_prefix, + _parse_frontmatter, + _lenient_frontmatter, render_arguments, ) +class TestLenientFrontmatter: + """Frontmatter that isn't strict YAML must still yield discovery fields. + + Real third-party skill catalogs (e.g. Genesis) ship SKILL.md files whose + unquoted ``description`` contains a colon (``...readiness levels: Level + 1...``) — invalid YAML. These must be recovered, not dropped from discovery. + """ + + def test_unquoted_colon_in_description_is_recovered(self, tmp_path): + path = tmp_path / "SKILL.md" + path.write_text( + "---\n" + "name: generating-datacards\n" + "description: Generates a datacard. Supports levels: Level 1, Level 2.\n" + "---\n\n# Body\n" + ) + spec = _parse_frontmatter(path) # must NOT raise + assert spec["name"] == "generating-datacards" + assert spec["description"].startswith("Generates a datacard") + assert "Level 1" in spec["description"] # colon-bearing tail preserved + + def test_lenient_parses_inline_list_and_continuation(self): + spec = _lenient_frontmatter( + "\nname: x\ndescription: a: b: c\ntags: [one, two]\n" + ) + assert spec["name"] == "x" + assert spec["description"] == "a: b: c" # split on first colon only + assert spec["tags"] == ["one", "two"] + + def test_valid_yaml_still_uses_strict_path(self, tmp_path): + # Sanity: well-formed frontmatter is unchanged by the fallback. + path = tmp_path / "SKILL.md" + path.write_text("---\nname: ok\ndescription: clean\ntags:\n - a\n---\n") + spec = _parse_frontmatter(path) + assert spec == {"name": "ok", "description": "clean", "tags": ["a"]} + + # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @@ -87,6 +129,7 @@ def empty_registry(tmp_path): # list_tools # --------------------------------------------------------------------------- + class TestListTools: def test_schema_structure(self, registry): @@ -146,6 +189,7 @@ def test_no_params_tool(self, tmp_path): # get_tool # --------------------------------------------------------------------------- + class TestGetTool: def test_found(self, registry): @@ -164,6 +208,7 @@ def test_not_found(self, registry): # save_tool # --------------------------------------------------------------------------- + class TestSaveTool: def test_add_new_tool(self, empty_registry): @@ -177,27 +222,41 @@ def test_add_new_tool(self, empty_registry): def test_wraps_executable_with_dsagt_run(self, empty_registry): """save_tool automatically wraps the executable with dsagt-run.""" - empty_registry.save_tool({"name": "mytool", "description": "test", - "executable": "python mytool.py", "parameters": {}}) + empty_registry.save_tool( + { + "name": "mytool", + "description": "test", + "executable": "python mytool.py", + "parameters": {}, + } + ) tool = empty_registry.get_tool("mytool") assert tool["executable"] == "dsagt-run --tool mytool -- python mytool.py" def test_does_not_double_wrap(self, empty_registry): """If executable already has dsagt-run, don't wrap again.""" - empty_registry.save_tool({"name": "mytool", "description": "test", - "executable": "dsagt-run --tool mytool -- python mytool.py", - "parameters": {}}) + empty_registry.save_tool( + { + "name": "mytool", + "description": "test", + "executable": "dsagt-run --tool mytool -- python mytool.py", + "parameters": {}, + } + ) tool = empty_registry.get_tool("mytool") assert tool["executable"].count("dsagt-run") == 1 def test_python_deps_use_uv_run(self, empty_registry): """Python dependencies are wrapped with uv run --with.""" - empty_registry.save_tool({ - "name": "analyzer", "description": "test", - "executable": "python analyzer.py", - "parameters": {}, - "dependencies": ["pandas>=2.0", "numpy"], - }) + empty_registry.save_tool( + { + "name": "analyzer", + "description": "test", + "executable": "python analyzer.py", + "parameters": {}, + "dependencies": ["pandas>=2.0", "numpy"], + } + ) tool = empty_registry.get_tool("analyzer") assert tool["executable"] == ( "dsagt-run --tool analyzer -- uv run --with pandas>=2.0,numpy -- python analyzer.py" @@ -205,8 +264,14 @@ def test_python_deps_use_uv_run(self, empty_registry): def test_no_deps_no_uv_run(self, empty_registry): """Tools without dependencies don't get uv run prefix.""" - empty_registry.save_tool({"name": "simple", "description": "test", - "executable": "echo hi", "parameters": {}}) + empty_registry.save_tool( + { + "name": "simple", + "description": "test", + "executable": "echo hi", + "parameters": {}, + } + ) tool = empty_registry.get_tool("simple") assert "uv run" not in tool["executable"] assert tool["executable"] == "dsagt-run --tool simple -- echo hi" @@ -247,6 +312,7 @@ def test_update_overwrites_frontmatter(self, empty_registry): # Runtime isolation # --------------------------------------------------------------------------- + class TestRuntimeIsolation: def test_source_unchanged_after_init(self, tmp_path): @@ -277,6 +343,7 @@ def test_source_unchanged_after_init(self, tmp_path): # Default skills # --------------------------------------------------------------------------- + class TestDefaultTools: """Validate the tool files that ship with the package.""" @@ -310,6 +377,7 @@ def test_default_init_fallback(self, tmp_path): # render_arguments # --------------------------------------------------------------------------- + class TestRenderArguments: def test_default_cli_is_double_dash_name(self): @@ -353,7 +421,8 @@ def test_positionals_before_named(self): "path": {"type": "string", "cli": "positional:0"}, } assert render_arguments(params, {"path": "/x", "verbose": True}) == [ - "/x", "--verbose", + "/x", + "--verbose", ] def test_boolean_true_emits_flag(self): @@ -378,7 +447,9 @@ def test_optional_missing_skipped(self): assert render_arguments(params, {}) == [] def test_required_missing_raises(self): - params = {"directory": {"type": "string", "cli": "positional", "required": True}} + params = { + "directory": {"type": "string", "cli": "positional", "required": True} + } with pytest.raises(ValueError, match="directory"): render_arguments(params, {}) From 1bbc7ffc1d5fc05e3149f82ae2b5faaa3e7b511a Mon Sep 17 00:00:00 2001 From: aarontuor Date: Wed, 24 Jun 2026 13:30:43 -0700 Subject: [PATCH 16/44] =?UTF-8?q?chore(release):=200.2.0=20=E2=80=94=20ext?= =?UTF-8?q?ernal=20skill=20catalogs=20+=20single=20dsagt-server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate the branch into one 0.2.0 release: a single CHANGELOG entry (external skill catalogs incl. the Genesis integration, native discovery, catalog-only search + keyword fallback, license/PROVENANCE capture, server merge) replacing the interim 0.2.0/0.3.0 entries; version set to 0.2.0 (one minor bump from master's 0.1.0). README/architecture diagram refreshed. Add two skill-management use cases: - isaac_skills_demo — agent-led arc (what we have → find more → sync → install → create) that authors a vasp-to-isaac converter parsing with real pymatgen. - genesis_skills — pull Genesis curation skills, ingest domain into the KB, and generate a datacard for a finished dataset. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 117 ++++---- README.md | 6 +- docs/assets/architecture.png | Bin 119777 -> 334304 bytes latex/architecture.png | Bin 119777 -> 334304 bytes src/dsagt/__init__.py | 2 +- use_cases/genesis_skills/README.md | 175 ++++++++++++ .../mock_data/dataset/catalyst_screening.csv | 9 + .../mock_data/domain/data_dictionary.md | 25 ++ .../mock_data/domain/measurement_protocol.md | 40 +++ .../mock_data/expected_datacard.md | 43 +++ use_cases/isaac_skills_demo/PROMPTS.md | 109 ------- use_cases/isaac_skills_demo/README.md | 267 ++++++++++-------- 12 files changed, 513 insertions(+), 280 deletions(-) create mode 100644 use_cases/genesis_skills/README.md create mode 100644 use_cases/genesis_skills/mock_data/dataset/catalyst_screening.csv create mode 100644 use_cases/genesis_skills/mock_data/domain/data_dictionary.md create mode 100644 use_cases/genesis_skills/mock_data/domain/measurement_protocol.md create mode 100644 use_cases/genesis_skills/mock_data/expected_datacard.md delete mode 100644 use_cases/isaac_skills_demo/PROMPTS.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e15b46..89cef74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,20 +6,25 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] -## [0.3.0] - 2026-06-23 +## [0.2.0] - 2026-06-24 -This release consolidates the agent-facing surface. The registry and knowledge -MCP servers were two processes that each loaded their own embedder and opened -their own ChromaDB — pure duplication, plus a write-here/read-there hazard on -the shared skill-catalog collections. They collapse into one `dsagt-server` -(one embedder, one Chroma owner, one connection per agent), so startup is faster -and there are fewer moving parts per project. In parallel, skill discovery now -leans on each agent's *native* SKILL.md auto-discovery, so `search_skills` is -reserved for the one job native discovery can't do — browsing the external -catalog of skills you haven't installed yet. +This release adds an **external skill-catalog system** and consolidates the +agent-facing surface into a **single MCP server**. -**Upgrading (forwards compatibility).** There is no automatic migration — -adopting 0.3.0 is rebuild-not-migrate, and no project data changes: +Agents can now discover and install skills from federated GitHub/GitLab catalogs +(Genesis, Anthropic, K-Dense, and more) and author their own — while installed +skills are picked up through each agent's *native* `SKILL.md` auto-discovery, so +`search_skills` is reserved for the one job native discovery can't do: browsing +the catalog of skills you haven't installed yet. + +In parallel, the registry and knowledge MCP servers — two processes that each +loaded their own embedder and opened their own ChromaDB (pure duplication, plus +a write-here/read-there hazard on the shared skill-catalog collections) — +collapse into one `dsagt-server`: one embedder, one Chroma owner, one connection +per agent, so startup is faster with fewer moving parts per project. + +**Upgrading from 0.1.0 (forwards compatibility).** There is no automatic +migration — adopting 0.2.0 is rebuild-not-migrate, and no project data changes: - Re-run `dsagt start ` for each existing project; it regenerates the per-agent MCP config to point at the single `dsagt-server`. - For **cline** only, delete `/.cline-data` first — `cline mcp add` @@ -28,51 +33,50 @@ adopting 0.3.0 is rebuild-not-migrate, and no project data changes: - Tools, skills, the KB index, traces, and memory all carry over untouched. ### Added -- Source-qualified catalog install: when the same skill name exists in more +- **External skill catalogs**: discover and install agent skills from GitHub / + GitLab sources via `add_skill_source`, `search_skills`, and `install_skill` + (plus the `dsagt skills sync/add/list/search` CLI), backed by per-source + ChromaDB collections. Curated sources ship out of the box (`scientific`, + `anthropic`, `antigravity`, `composio`, `genesis`); any git URL / `owner/repo` + also works. +- **Genesis catalog integration**: the curated `genesis` source (OSTI GitLab, + `gitlab.osti.gov/genesis/genesis-skills`) makes the BASE-Data / ModCon skills + — `datacard-generator` (frontmatter name `generating-datacards`), + `croissant-validator`, `hdmf-schema-builder` — pullable on demand + (`dsagt skills add genesis`, then `install_skill`) rather than + bundled in the package, alongside the rest of the Genesis catalog (HPC/Slurm, + HuggingFace, LangChain, and more). +- **Native skill discovery**: installed and bundled skills are mirrored into + each agent's native skill directory (`.claude/skills/`, `.agents/skills/`, …) + at init/start, so every supported agent auto-discovers them. +- **`skill-creator`** bundled skill for authoring new skills from the Anthropic + template. +- **Source-qualified catalog install**: when the same skill name exists in more than one synced source, install a specific one with a `/` name (via `install_skill` or `dsagt skills add /`) instead of dead-ending on the ambiguity guard. +- **Keyword fallback** for `search_skills`: a zero-dependency token-overlap + scorer so catalog search works even when no embedding model is configured. +- **License / attribution provenance on install**: installing a catalog skill + preserves upstream `LICENSE` / `NOTICE` files and stamps a `PROVENANCE.txt` + recording the source repo and path into the installed skill directory. +- **`isaac_skills_demo` use case**: an end-to-end, skill-oriented walkthrough + (`use_cases/isaac_skills_demo/`) that drives a real agent through syncing a + catalog, installing a skill, and converting mock VASP output into an Isaac + record — with prompts and mock data included. +- **Install-from-GitHub instructions** for non-developers (`pip install + git+https://github.com/AI-ModCon/dsagt.git` into any Python 3.12/3.13 + environment) in the README and docs. ### Changed - **The two MCP servers are now one `dsagt-server`** — one shared `KnowledgeBase`/embedder, one MCP entry per agent, one trace `service.name`. + The tool surface is organized by concern (registry / knowledge / memory / + skill) behind the single server. - Skill discovery is now **catalog-only**: installed and bundled skills are discovered natively by every supported agent, so `search_skills` covers only the not-yet-installed external catalog. Catalogs are indexed on frontmatter (name + description + tags) rather than the full SKILL.md body. -- `search_skills` gains a zero-dependency **keyword fallback** (a token-overlap - scorer) so it works even when no embedding model is configured. -- Installing a catalog skill now preserves upstream `LICENSE`/`NOTICE` - provenance and stamps a `PROVENANCE.txt` into the installed skill directory. - -### Removed -- **BREAKING:** the `dsagt-registry-server` and `dsagt-knowledge-server` console - scripts, replaced by `dsagt-server` (see **Upgrading** above). -- The bundled `datacard-generator` skill — it lives in the Genesis catalog and - is now installed on demand via `dsagt skills add genesis`. -- Dead indexing of installed/bundled skills into the `skills` ChromaDB - collection (nothing read it after the catalog-only search change). - -### Fixed -- `dsagt --version` now works (it was documented but unimplemented — argparse - errored). Reports the version from `dsagt.__version__`. - -## [0.2.0] - 2026-06-23 - -### Added -- External skill catalogs: discover and install agent skills from GitHub - sources via `add_skill_source`, `search_skills`, and `install_skill` (plus - the `dsagt skills sync/add/list/search` CLI), backed by per-source ChromaDB - collections. -- Native skill discovery: installed and bundled skills are mirrored into the - agent's native skill directory (e.g. `.claude/skills/`) at init/start. -- `skill-creator` bundled skill for authoring new skills from the Anthropic - template. -- Install-from-GitHub instructions for non-developers (`pip install - git+https://github.com/AI-ModCon/dsagt.git` into any Python 3.12/3.13 - environment) in the README and docs. - -### Changed - `search_skills` now reports when no external catalog is synced instead of a bare "no match", and `list_skill_sources` flags each known source as `synced`/available with its indexed count. @@ -85,8 +89,24 @@ adopting 0.3.0 is rebuild-not-migrate, and no project data changes: and install instructions directly from the README via the `mkdocs-include-markdown` plugin, so the two no longer drift. +### Removed +- **BREAKING:** the `dsagt-registry-server` and `dsagt-knowledge-server` console + scripts, replaced by `dsagt-server` (see **Upgrading** above). +- The bundled `datacard-generator` skill — it lives in the Genesis catalog and + is now installed on demand via `dsagt skills add genesis`. +- Dead indexing of installed/bundled skills into the `skills` ChromaDB + collection (nothing read it after the catalog-only search change). + ### Fixed - CLI-added skill sources are now persisted to the project config. +- `dsagt --version` now works (it was documented but unimplemented — argparse + errored). Reports the version from `dsagt.__version__`. +- Catalog skills with technically-invalid YAML frontmatter (e.g. an unquoted + `description` containing a colon, like `…readiness levels: Level 1…`) are no + longer silently dropped from discovery. `_parse_frontmatter` falls back to a + lenient flat parse that recovers `name`/`description`/`tags`, so such skills — + including Genesis's `generating-datacards` (`datacard-generator`) — are + searchable and installable instead of skipped. ## [0.1.0] - 2026-01-11 @@ -95,7 +115,6 @@ adopting 0.3.0 is rebuild-not-migrate, and no project data changes: generation, MLflow/OTel observability, the tool/skill registry, execution provenance, and explicit + episodic memory. -[Unreleased]: https://github.com/AI-ModCon/dsagt/compare/v0.3.0...HEAD -[0.3.0]: https://github.com/AI-ModCon/dsagt/compare/v0.2.0...v0.3.0 -[0.2.0]: https://github.com/AI-ModCon/dsagt/releases/tag/v0.2.0 +[Unreleased]: https://github.com/AI-ModCon/dsagt/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/AI-ModCon/dsagt/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/AI-ModCon/dsagt/releases/tag/v0.1.0 diff --git a/README.md b/README.md index 2646289..c73984b 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,10 @@ If you just want to *run* DSAgt against your own data and agent — no repo chec python3.12 -m venv ~/.venvs/dsagt # or: conda create -n dsagt python=3.12 && conda activate dsagt source ~/.venvs/dsagt/bin/activate # (Windows venv: ~\.venvs\dsagt\Scripts\activate) pip install "git+https://github.com/AI-ModCon/dsagt.git" -dsagt --version # 0.3.0 +dsagt --version # 0.2.0 ``` -This puts the `dsagt` CLI (and the `dsagt-run` / `dsagt-*-server` helpers) on your PATH. Then build the shared knowledge base once and create your first project: +This puts the `dsagt` CLI (and the `dsagt-run` / `dsagt-server` helpers) on your PATH. Then build the shared knowledge base once and create your first project: ```bash dsagt setup-kb # bundled tools + skills + reference corpora @@ -49,7 +49,7 @@ pip install --upgrade "git+https://github.com/AI-ModCon/dsagt.git" dsagt setup-kb ``` -> Pin to a specific release once tags are published, e.g. `pip install "git+https://github.com/AI-ModCon/dsagt.git@v0.3.0"`. +> Pin to a specific release once tags are published, e.g. `pip install "git+https://github.com/AI-ModCon/dsagt.git@v0.2.0"`. ### For development diff --git a/docs/assets/architecture.png b/docs/assets/architecture.png index 65f31a7148ae64ff5848170bec387fa701df5cb3..013ca208d04f9694ad303ddb8e43eb40157a7583 100644 GIT binary patch literal 334304 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≈FhgE_<;#0@Pz8BQXa5D1 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%0D$V;s~pX9GGE;k}5&gg5R1TO0%^@MhLlWEAD{? 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 bfB4Uy==*WSig5XB3_#%N>gTe~DWM4fB^}xC literal 119777 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%0D7Jbq4YOsUzaT>7tL`0)aqx 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 diff --git a/latex/architecture.png b/latex/architecture.png index 65f31a7148ae64ff5848170bec387fa701df5cb3..013ca208d04f9694ad303ddb8e43eb40157a7583 100644 GIT binary patch literal 334304 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≈FhgE_<;#0@Pz8BQXa5D1 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%0D$V;s~pX9GGE;k}5&gg5R1TO0%^@MhLlWEAD{? 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 bfB4Uy==*WSig5XB3_#%N>gTe~DWM4fB^}xC literal 119777 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%0D7Jbq4YOsUzaT>7tL`0)aqx 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 diff --git a/src/dsagt/__init__.py b/src/dsagt/__init__.py index 02c75b9..8337be1 100644 --- a/src/dsagt/__init__.py +++ b/src/dsagt/__init__.py @@ -6,7 +6,7 @@ # Single source of truth for the package version: pyproject.toml reads this # via `[tool.setuptools.dynamic] version = {attr = "dsagt.__version__"}`. -__version__ = "0.3.0" +__version__ = "0.2.0" # Cap CPU thread count for embedding / tokenization libraries before any # heavy imports happen. Without this, PyTorch / sentence-transformers / diff --git a/use_cases/genesis_skills/README.md b/use_cases/genesis_skills/README.md new file mode 100644 index 0000000..8c22c50 --- /dev/null +++ b/use_cases/genesis_skills/README.md @@ -0,0 +1,175 @@ +# DSAgt Demo: Genesis Skills for a Data-Curation Pipeline + +An end-to-end **data-preparation** walkthrough that flexes the skill catalog +against the **Genesis** source (OSTI GitLab). The agent pulls in the +BASE-Data/ModCon curation skills, grounds itself in domain context loaded into +the **knowledge base**, then prepares and **datacards a finished dataset**. + +The "finished product" is a small curated dataset — a CO2-methanation **catalyst +screen** (`mock_data/dataset/catalyst_screening.csv`, 8 rows) — plus the domain +docs that describe how it was produced. Everything is tiny, so the whole thing +runs in seconds with no real instruments or HPC. + +## What this demonstrates + +1. **Genesis skills, agent-facing.** `add_skill_source genesis` syncs the OSTI + GitLab catalog; `search_skills` finds the ModCon curation skills + (`generating-datacards`, `croissant-validator`); `install_skill` draws them + into the project where the agent **natively** auto-discovers them. +2. **Domain → knowledge base.** `kb_ingest` indexes the data dictionary + + measurement protocol into a project collection, so the agent describes + methods and provenance accurately (via `kb_search`) instead of guessing. +3. **Datacard for a finished product.** The installed `generating-datacards` + skill runs over the dataset — pulling field definitions, methodology, and + license from the KB-ingested domain docs — and emits a datacard, then + `croissant-validator` checks the Croissant metadata. + +The three tiers in one sentence: **catalog = searchable but not in context; +installed = native and auto-invoked; KB = retrievable domain grounding.** + +## Setup + +Assumes DSAgt (`uv sync --all-groups`) and Claude Code +(`npm i -g @anthropic-ai/claude-code`) are installed, plus git **with network +access to `gitlab.osti.gov`** (the Genesis catalog clones from OSTI GitLab, not +GitHub). Embedding credentials are optional — `search_skills` / `kb_search` use +semantic search when `EMBEDDING_*` is set and fall back to a keyword scorer +otherwise (configure it for sharper relevance over the domain docs). + +```bash +dsagt setup-kb # bundled tools/skills + core KB +dsagt init genesis-skills --agent claude +cp -r use_cases/genesis_skills/mock_data ~/dsagt-projects/genesis-skills/mock_data +dsagt start genesis-skills # mirrors skill-creator into .claude/skills/ before launch +``` + +The Genesis catalog is **project-scoped** and not synced by `init`, so the agent +enables it in step 1 below (or run `dsagt skills add genesis-skills genesis` from +a shell first). + +## Walkthrough + +Paste each prompt into Claude Code (running inside the project), one at a time, +and check the expected behavior. + +### 1 — Enable the Genesis source +> Enable the "genesis" skill source so we have the GENESIS / ModCon data-curation skills available. Then tell me how many skills it indexed. + +*Expect:* `add_skill_source(source="genesis")` → a shallow clone of OSTI GitLab, +**74** skills indexed into `skills_catalog__genesis-genesis-skills`, source +written to `dsagt_config.yaml`. (Two upstream skills — including +`datacard-generator` — have technically-invalid YAML frontmatter; dsagt recovers +their name/description with a lenient fallback rather than dropping them, so they +*are* searchable.) Confirm from a shell: + +```bash +dsagt skills list genesis-skills --catalog # expect the skills_catalog__genesis-genesis-skills collection +``` + +### 2 — Find and install the datacard skill (catalog, NOT in context) +Search for the two deliverables **separately** — a single "datacard *and* Croissant" +query lets the validator outrank the generator. First the datacard generator: + +> Search the catalog for a skill that creates a datacard / dataset documentation for a dataset, then install the best match into this project. + +*Expect:* `search_skills` (catalog hits tagged `[catalog · install_skill to add]`, +**`generating-datacards`** ranked top) → `install_skill(skill_name="generating-datacards")`; +the reply notes it'll be native after the next start and that a `PROVENANCE.txt` +(Genesis source) was written. + +### 3 — Find and install the Croissant validator +> Now search the catalog for a skill that validates Croissant / JSON-LD dataset metadata, and install the best match. + +*Expect:* `search_skills` (**`croissant-validator`** ranked top) → +`install_skill(skill_name="croissant-validator")`. **Verify** both installs (each +lands with any `scripts/`/`references/`): + +```bash +ls ~/dsagt-projects/genesis-skills/skills/ +cat ~/dsagt-projects/genesis-skills/skills/generating-datacards/PROVENANCE.txt +``` + +### 4 — Ingest the domain docs into the KB +> Ingest the domain docs under mock_data/domain/ into a new knowledge-base collection called "methanation_domain". Poll until it finishes, then tell me what's in it. + +*Expect:* `kb_ingest(folder_path="mock_data/domain", collection_name="methanation_domain")` +returns a `job_id`; the agent polls `kb_job_status` to completion, then +`kb_list_collections` shows `methanation_domain` (2 docs). **Verify:** + +```bash +dsagt info genesis-skills # the new collection appears in the KB summary +``` + +### 5 — Retrieve domain grounding +> Using the knowledge base, what reactor conditions were used for the CO2 conversion measurement, and what license applies to this dataset? + +*Expect:* `kb_search` over `methanation_domain` → **250 °C, 1 atm, H2:CO2 = 4:1, +GHSV 12,000**; license **CC-BY-4.0** (pulled from the protocol doc, not guessed). + +### 6 — Generate the datacard for the finished dataset +> Use the generating-datacards skill to write a datacard for mock_data/dataset/catalyst_screening.csv. Pull the field definitions, measurement methodology, provenance, and license from the methanation_domain knowledge-base collection — don't invent them. Save it to audit/catalyst_screening_datacard.md. Then compare your sections against mock_data/expected_datacard.md and report anything missing. + +*Expect:* the agent reads the installed skill's `SKILL.md`, queries the KB, +computes basic stats from the 8-row CSV, and writes +`audit/catalyst_screening_datacard.md` covering summary / provenance / schema / +methodology / stats / limitations / license. + +### 7 — Validate the metadata +> Use the croissant-validator skill to check the Croissant/JSON-LD metadata for this dataset (generate it from the datacard if needed), and report any schema errors. + +*Expect:* the validator skill runs and reports a clean pass or names specific +schema issues. + +### 8 — Inspect the tiers (run in a shell) +```bash +dsagt skills list genesis-skills # installed: skill-creator + generating-datacards + croissant-validator +dsagt skills list genesis-skills --catalog # catalog: skills_catalog__genesis-genesis-skills +dsagt info genesis-skills # KB shows methanation_domain +ls ~/dsagt-projects/genesis-skills/.claude/skills/ +cat ~/dsagt-projects/genesis-skills/.claude/skills/.dsagt-managed.json +ls ~/dsagt-projects/genesis-skills/audit/ # catalyst_screening_datacard.md +``` + +Restart Claude (`dsagt start genesis-skills` again) to pick up the installed +Genesis skills as native auto-invoked skills. + +## Post-Conditions + +1. The KB holds a `skills_catalog__genesis-genesis-skills` collection + (searchable via `search_skills`, absent from Claude's context) **and** a + `methanation_domain` document collection (retrievable via `kb_search`). +2. `generating-datacards` and `croissant-validator` are installed into + `/skills/`, mirrored into `.claude/skills/`, each with a + `PROVENANCE.txt` crediting the Genesis source. +3. `audit/catalyst_screening_datacard.md` was produced for the finished dataset, + grounded in the KB-ingested domain docs, covering the sections in + `mock_data/expected_datacard.md`. +4. The Croissant metadata validates (or its errors are reported). +5. `.claude/skills/.dsagt-managed.json` tracks exactly the dsagt-placed skills. + +## Cleanup + +```bash +dsagt stop genesis-skills +dsagt rm genesis-skills # add -y to skip the prompt +``` + +The shared catalog cache lives at `~/dsagt-projects/.skill_sources/` and is +reused across projects; delete it to force a fresh clone. + +## Notes + +- The Genesis catalog is hosted on **OSTI GitLab** (`gitlab.osti.gov`), not + GitHub — reached the same way as any other source (`add_skill_source` / + `dsagt skills add … genesis`); only the host differs. +- `generating-datacards` is the frontmatter *name* of the skill whose directory + is `datacard-generator` in the Genesis repo — `install_skill` accepts either. + It and `croissant-validator` live under Genesis's `modcon-skills/` category + (the BASE-Data team's own skills), so this demo is DSAgt consuming its sibling + project's curated skills. +- `mock_data/` is intentionally tiny and illustrative. `expected_datacard.md` is + a *shape* to check coverage against, not a byte-for-byte answer — the installed + skill owns the authoritative template. +- Sister demo: [`isaac_skills_demo`](../isaac_skills_demo/) flexes the same + catalog → install → native-mirror loop plus authoring a new skill with + `skill-creator`, against the K-Dense `scientific` (GitHub) source. diff --git a/use_cases/genesis_skills/mock_data/dataset/catalyst_screening.csv b/use_cases/genesis_skills/mock_data/dataset/catalyst_screening.csv new file mode 100644 index 0000000..b18c4b9 --- /dev/null +++ b/use_cases/genesis_skills/mock_data/dataset/catalyst_screening.csv @@ -0,0 +1,9 @@ +sample_id,composition,calcination_temp_C,bet_surface_area_m2_g,co2_conversion_pct,ch4_selectivity_pct,test_date,operator +CAT-001,Ni/Al2O3,500,142.3,38.5,91.2,2026-03-02,jlee +CAT-002,Ni-Ce/Al2O3,500,138.7,46.1,93.8,2026-03-02,jlee +CAT-003,Ni-Ce/Al2O3,650,121.4,52.7,95.1,2026-03-03,jlee +CAT-004,Ru/TiO2,400,88.6,29.3,88.4,2026-03-03,akumar +CAT-005,Ru-K/TiO2,400,85.1,34.8,90.7,2026-03-04,akumar +CAT-006,Co/SiO2,550,210.9,41.2,84.6,2026-03-04,akumar +CAT-007,Co-Mn/SiO2,550,198.3,49.5,87.9,2026-03-05,jlee +CAT-008,Fe/ZrO2,600,76.2,33.1,79.5,2026-03-05,akumar diff --git a/use_cases/genesis_skills/mock_data/domain/data_dictionary.md b/use_cases/genesis_skills/mock_data/domain/data_dictionary.md new file mode 100644 index 0000000..db37622 --- /dev/null +++ b/use_cases/genesis_skills/mock_data/domain/data_dictionary.md @@ -0,0 +1,25 @@ +# Data Dictionary — CO2 Methanation Catalyst Screening + +One row per catalyst sample tested in the fixed-bed reactor screen. + +| Column | Type | Units | Definition | +|---|---|---|---| +| `sample_id` | string | — | Unique catalyst identifier (`CAT-NNN`). Primary key. | +| `composition` | string | — | Active metal / promoter / support, e.g. `Ni-Ce/Al2O3` = Ni + Ce promoter on alumina. | +| `calcination_temp_C` | integer | °C | Calcination temperature during catalyst preparation. | +| `bet_surface_area_m2_g` | float | m²/g | BET specific surface area (N2 physisorption, see protocol). | +| `co2_conversion_pct` | float | % | CO2 converted at steady state (250 °C, 1 atm, H2:CO2 = 4:1). | +| `ch4_selectivity_pct` | float | % | Carbon selectivity to CH4 (balance: CO + higher hydrocarbons). | +| `test_date` | date | ISO 8601 | Date the reactor run was performed. | +| `operator` | string | — | Lab notebook operator initials. | + +## Controlled vocabularies + +- **Supports:** `Al2O3`, `TiO2`, `SiO2`, `ZrO2`. +- **Promoters:** `Ce`, `K`, `Mn` (optional; absent for unpromoted catalysts). + +## Quality rules + +- `co2_conversion_pct` and `ch4_selectivity_pct` are in `[0, 100]`. +- `bet_surface_area_m2_g > 0`. +- Every `sample_id` is unique and matches `^CAT-\d{3}$`. diff --git a/use_cases/genesis_skills/mock_data/domain/measurement_protocol.md b/use_cases/genesis_skills/mock_data/domain/measurement_protocol.md new file mode 100644 index 0000000..7ce2251 --- /dev/null +++ b/use_cases/genesis_skills/mock_data/domain/measurement_protocol.md @@ -0,0 +1,40 @@ +# Measurement Protocol — Methanation Screen v2 + +This protocol describes how the values in `catalyst_screening.csv` were +produced. It is domain context for the curation agent: ingest it into the +knowledge base so the agent can describe methods + provenance accurately in the +datacard without re-deriving them. + +## Catalyst preparation + +Catalysts were prepared by incipient-wetness impregnation of the support with +aqueous metal-nitrate precursors, dried at 110 °C overnight, and calcined in +static air for 4 h at the temperature recorded in `calcination_temp_C`. +Promoted samples (Ce, K, Mn) were co-impregnated. + +## BET surface area + +Specific surface area (`bet_surface_area_m2_g`) was measured by N2 physisorption +at 77 K on a Micromeritics ASAP 2020, multipoint BET in the relative-pressure +range 0.05–0.30, after degassing at 200 °C for 6 h. + +## Reactor screen + +Activity was measured in a fixed-bed quartz reactor, 50 mg catalyst diluted with +SiC, at **250 °C, 1 atm, H2:CO2 = 4:1, GHSV = 12,000 mL·g⁻¹·h⁻¹**. Steady state +was reached after 60 min on stream. Effluent was analyzed by online GC-TCD/FID. + +- `co2_conversion_pct` = (CO2_in − CO2_out) / CO2_in × 100 +- `ch4_selectivity_pct` = CH4 carbon / (total converted carbon) × 100 + +## Known limitations + +- Single-run measurements (no replicate error bars in this screen). +- Selectivity excludes trace C2+ (< 0.5 %), lumped into the balance. +- Intended for **relative** ranking of formulations, not absolute kinetics. + +## Provenance + +- Instrument campaign: `methanation-screen-2026Q1` +- Raw GC traces + reactor logs archived in the lab LIMS under the same campaign id. +- License for the curated dataset: **CC-BY-4.0**. diff --git a/use_cases/genesis_skills/mock_data/expected_datacard.md b/use_cases/genesis_skills/mock_data/expected_datacard.md new file mode 100644 index 0000000..4c57c96 --- /dev/null +++ b/use_cases/genesis_skills/mock_data/expected_datacard.md @@ -0,0 +1,43 @@ +# Expected Datacard Shape (reference target) + +This is the *shape* the generated datacard should cover — not a byte-for-byte +answer. The installed `generating-datacards` skill owns the authoritative +template; this file just lists the sections the agent should populate from the +dataset + the KB-ingested domain docs, so you can spot anything missing. + +--- + +## Dataset: CO2 Methanation Catalyst Screen (2026 Q1) + +**Summary.** 8 catalyst formulations screened for CO2 methanation activity and +CH4 selectivity in a fixed-bed reactor. One row per sample. + +### Provenance +- Campaign: `methanation-screen-2026Q1` +- Curated from `catalyst_screening.csv`; methods per the lab protocol. +- License: **CC-BY-4.0** + +### Schema +A row per `sample_id` with: `composition`, `calcination_temp_C` (°C), +`bet_surface_area_m2_g` (m²/g), `co2_conversion_pct` (%), `ch4_selectivity_pct` +(%), `test_date`, `operator`. (Field units + definitions pulled from the data +dictionary.) + +### Collection methodology +- Catalysts: incipient-wetness impregnation, calcined 4 h in static air. +- BET: N2 physisorption, 77 K, multipoint (P/P0 0.05–0.30). +- Activity: 250 °C, 1 atm, H2:CO2 = 4:1, GHSV 12,000 mL·g⁻¹·h⁻¹, steady state at 60 min. + +### Descriptive statistics (computed from the data) +- Rows: 8; samples unique; no missing values. +- `co2_conversion_pct`: range ~29–53 %. +- `ch4_selectivity_pct`: range ~80–95 %. + +### Limitations / caveats +- Single-run (no replicate error bars). +- Relative ranking, not absolute kinetics. +- Selectivity excludes trace C2+ (< 0.5 %). + +### Quality checks +- `sample_id` matches `^CAT-\d{3}$`, unique. +- Percentages in `[0, 100]`; `bet_surface_area_m2_g > 0`. diff --git a/use_cases/isaac_skills_demo/PROMPTS.md b/use_cases/isaac_skills_demo/PROMPTS.md deleted file mode 100644 index cf29800..0000000 --- a/use_cases/isaac_skills_demo/PROMPTS.md +++ /dev/null @@ -1,109 +0,0 @@ -# Hand-pass prompt script — isaac_skills_demo - -The deterministic backbone (init, catalog sync, CLI `skills` commands, the -native mirror) was already vetted by a first pass — see "First-pass results" -at the bottom. This script is for the **interactive agent pass**: paste each -prompt into Claude Code (running inside the project) and check the expected -behavior. - -## Before you start - -The project `isaac-skills-demo` is already set up from the first pass: -- catalog synced (146 K-Dense + 17 Anthropic skills), -- `pymatgen` already installed and mirrored into `.claude/skills/`, -- `mock_data/` copied into the project. - -To start fresh instead, delete and rebuild — **including the catalog sync** -(a fresh `init` only copies the *shared* KB; the external catalog is -project-scoped, so it must be synced after init or the catalog will be empty -and prompt 2 finds nothing): - -```bash -dsagt rm isaac-skills-demo -y -dsagt init isaac-skills-demo --agent claude -cp -r use_cases/isaac_skills_demo/mock_data ~/dsagt-projects/isaac-skills-demo/mock_data -dsagt skills sync isaac-skills-demo # REQUIRED: populate the catalog (146 skills) -``` - -> Alternatively, run the global `dsagt setup-kb` once (it syncs the default -> catalog into the *shared* KB, which every new `init` then copies in). The -> per-project `dsagt skills sync` above is the lighter, self-contained path. - -Confirm the catalog is present before launching: - -```bash -dsagt skills list isaac-skills-demo --catalog # expect a skills_catalog__* collection -``` - -Launch the agent: - -```bash -dsagt start isaac-skills-demo -``` - ---- - -## Prompts (paste one at a time) - -### 1 — Confirm native discovery of the bundled meta-skill -> What skills do you have available right now? List them and say which are dsagt-managed. - -*Expect:* `skill-creator`, `datacard-generator`, and (if you didn't rebuild) `pymatgen` are visible as native skills. - -### 2 — Search the catalog (NOT in context) -> Search the skill catalog for a skill that helps work with VASP, pymatgen, or DFT materials data. List what you find and which are installable from the catalog. - -*Expect:* `search_skills` is called; catalog hits are tagged `[catalog · install_skill to add]`; `pymatgen` ranks at/near the top. - -### 3 — Install from the catalog -> Install the most relevant materials/DFT skill you found from the catalog into this project. - -*Expect:* `install_skill(skill_name="pymatgen")`; reply notes it'll be native after the next start. (Already installed if you didn't rebuild — it should say "updated".) - -### 4 — Add a second catalog source via MCP -> Enable the "anthropic" skill source so we also have the official Anthropic skills available, then tell me how many skills that added to the catalog. - -*Expect:* `add_skill_source(source="anthropic")` → ~17 skills indexed; the source is written into `dsagt_config.yaml`. - -### 5 — List configured/synced sources -> List the skill sources currently configured and synced. - -*Expect:* `list_skill_sources` → `scientific` + `anthropic` known/synced. - -### 6 — Author a project skill with skill-creator -> Use the skill-creator skill to author a new project skill named "vasp-to-isaac-mock". It should: read a mock VASP calculation directory (POSCAR + INCAR + OUTCAR) under mock_data/mock_slab/, extract the final energy, atom count, and whether it's a slab relaxation (NSW > 0), and emit a small ISAAC-style JSON record. Use mock_data/expected_isaac_record.json as the shape to target. Save it with save_skill. - -*Expect:* the agent reads skill-creator's template + spec, then `save_skill` writes `/skills/vasp-to-isaac-mock/`. - -### 7 — Run the new skill on the mock data -> Invoke the vasp-to-isaac-mock workflow on mock_data/mock_slab/ and write the result to audit/mock_slab_isaac.json. Then diff its structure against mock_data/expected_isaac_record.json and report any missing fields. - -*Expect:* a produced `audit/mock_slab_isaac.json` with the key fields (final energy ≈ -132.8421 eV, 12 atoms, slab/NSW=50). Compare to the reference. - -### 8 — Inspect both tiers (run in a shell, not the agent) -```bash -dsagt skills list isaac-skills-demo -dsagt skills list isaac-skills-demo --catalog -ls ~/dsagt-projects/isaac-skills-demo/.claude/skills/ -cat ~/dsagt-projects/isaac-skills-demo/.claude/skills/.dsagt-managed.json -``` - -*Expect:* installed list includes `pymatgen` + `vasp-to-isaac-mock`; catalog lists both `skills_catalog__*` collections; the manifest tracks exactly the dsagt-placed skills (your hand-authored `.claude/skills/` entries, if any, are untouched). - ---- - -## First-pass results (already verified, no agent needed) - -| Step | Result | -|---|---| -| `dsagt init` | ✅ `.claude/skills/` mirror fired at init: `skill-creator` (+ refs) + `datacard-generator`; manifest correct; config `skills:` block present | -| `dsagt skills sync` | ✅ real clone of K-Dense, **146 skills** indexed into `skills_catalog__k-dense-ai-scientific-agent-skills` (~19s, local embeddings) | -| `dsagt skills list --catalog` | ✅ shows the catalog collection | -| `dsagt skills search "VASP pymatgen DFT materials"` | ✅ `pymatgen` top catalog hit; tiers tagged `[bundled]` / `[catalog:…]` | -| `dsagt skills add … pymatgen` | ✅ installed into `skills/pymatgen/` **with** `scripts/` + `references/` | -| start-equivalent re-mirror | ✅ `pymatgen` now in `.claude/skills/` + manifest | -| `dsagt skills add … anthropic` | ✅ second source cloned + **17 skills** indexed; source persisted to config | - -**Caveat to know:** with the default **local** embedding backend (`bge-small`), absolute search scores are low (~0.03) because short queries under-score long SKILL.md texts — *ranking* is still correct (pymatgen #1). An API embedding model scores higher. Set `EMBEDDING_*` / switch `embedding.backend` to `api` for sharper relevance. - -**Fix applied during the first pass:** the CLI `dsagt skills add ` path now also persists the source to `dsagt_config.yaml` (previously only the MCP `add_skill_source` tool did), so a later config-driven `dsagt skills sync` re-syncs it. Regression test added. diff --git a/use_cases/isaac_skills_demo/README.md b/use_cases/isaac_skills_demo/README.md index 200de44..16502d6 100644 --- a/use_cases/isaac_skills_demo/README.md +++ b/use_cases/isaac_skills_demo/README.md @@ -1,146 +1,155 @@ # DSAgt Demo: Skill-Driven VASP → ISAAC Conversion -A lightweight mock of the [`isaac_vasp`](../isaac_vasp/) workflow, built specifically to **vet the skill-management feature**. Instead of shipping a hand-written converter skill, this walkthrough has the agent **discover, install, and author skills** — and surfaces them through Claude Code's *native* skill discovery. - -It uses tiny **mock VASP outputs** (`mock_data/`, a few KB) so the whole thing runs in seconds with no DFT, no NERSC, and no 32 MB OUTCAR files. - -## What this demonstrates (the new functionality) - -- **External skill catalog** — pull Agent-Skills from GitHub repos (default: K-Dense `scientific-agent-skills`, 140+ skills) into a searchable catalog that is **not** loaded into the agent's context. -- **`search_skills`** spanning installed skills *and* the catalog (catalog hits marked `[catalog]`). -- **`install_skill`** — move a chosen catalog skill into the project, then into Claude's native `.claude/skills/`. -- **`add_skill_source`** — the agent enables another source (e.g. `anthropic`) via an MCP tool call. -- **`skill-creator`** — the bundled meta-skill scaffolds a brand-new `vasp-to-isaac-mock` skill from the Anthropic template. -- **Native mirror** — installed + bundled skills appear under `.claude/skills//` (tracked by `.dsagt-managed.json`), so Claude auto-invokes them with no MCP round-trip. - -The two tiers in one sentence: **catalog = searchable but not in context; installed = native and auto-invoked.** - -## Prerequisites - -- DSAgt installed (`uv sync --all-groups`) -- Claude Code installed (`npm i -g @anthropic-ai/claude-code`) -- Valid embedding credentials (so `search_skills` works) — `EMBEDDING_*` in your shell or project config -- Git installed (catalog sync uses a shallow clone) +A lightweight mock of the [`isaac_vasp`](../isaac_vasp/) workflow, built to **vet +the skill-management feature**. It follows the same arc as `isaac_vasp` — install +the **pymatgen** skill, author a `vasp-to-isaac` converter that parses VASP output +**with pymatgen**, and emit an ISAAC record — but the agent **discovers, syncs, +installs, and authors** those skills itself, surfaced through Claude Code's +*native* skill discovery. It uses tiny **mock VASP outputs** (`mock_data/`, a few +KB), so the whole thing runs in seconds with no DFT, no NERSC, and no 32 MB +OUTCAR files — yet the parsing is the real `pymatgen.io.vasp`, not a hand-rolled +stand-in. + +## What this demonstrates + +- **`list_skill_sources`** — the agent discovers what external sources it can + pull from (curated names + arbitrary git URLs) and which are synced. +- **`add_skill_source`** — the agent syncs a source (default: K-Dense + `scientific-agent-skills`, 140+ skills) into a searchable catalog that is + **not** loaded into context; with the single `dsagt-server` it's searchable + immediately, no restart. +- **`search_skills`** + **`install_skill`** — find a catalog skill (hits marked + `[catalog]`) and draw it into the project + Claude's native `.claude/skills/`. +- **`skill-creator`** — the bundled meta-skill scaffolds a new `vasp-to-isaac` + skill (from the Anthropic template) whose converter *uses the installed + pymatgen skill* to parse the VASP output — install-then-build, not install-and-ignore. +- **Native mirror** — installed + bundled skills appear under + `.claude/skills//` (tracked by `.dsagt-managed.json`), so Claude + auto-invokes them with no MCP round-trip. + +The two tiers in one sentence: **catalog = searchable but not in context; +installed = native and auto-invoked.** ## Setup -### 1. Build the core KB + default skill catalog - -```bash -dsagt setup-kb -``` - -This indexes the bundled tools/skills **and** clones + indexes the default skill source (K-Dense scientific). To skip the catalog, pass `--no-skill-catalog`. To go faster, you can defer the catalog and add it per-project later (Step A). - -### 2. Initialize a project +Assumes DSAgt (`uv sync --all-groups`) and Claude Code +(`npm i -g @anthropic-ai/claude-code`) are installed, plus git. Embedding +credentials are optional — `search_skills` uses semantic search when +`EMBEDDING_*` is set and falls back to a keyword scorer otherwise (configure it +for sharper relevance). ```bash +dsagt setup-kb --no-skill-catalog # core KB only — the agent syncs the catalog in-session (step 3) dsagt init isaac-skills-demo --agent claude -``` - -The generated `dsagt_config.yaml` already carries a `skills:` block with the `scientific` source enabled and `populate_native: true`. - -### 3. Start the session - -```bash -dsagt start isaac-skills-demo -``` - -`dsagt start` mirrors installed + bundled skills into `.claude/skills/` **before** launching Claude, so the bundled `skill-creator` is already discoverable. Copy the mock data into the project first so the agent can reach it: - -```bash cp -r use_cases/isaac_skills_demo/mock_data ~/dsagt-projects/isaac-skills-demo/mock_data +dsagt start isaac-skills-demo # mirrors the bundled skill-creator into .claude/skills/ before launch ``` -## Execution - -### A. Browse and install a skill from the catalog - -First confirm the catalog is searchable (these are NOT in Claude's context — they live in the KB): - -```bash -dsagt skills list isaac-skills-demo --catalog -``` - -You should see a `skills_catalog__k-dense-ai-scientific-agent-skills` collection. Now have the agent search it: - -```text -Search the skill catalog for a skill that helps work with VASP, pymatgen, or DFT materials data. List what you find and which are installable from the catalog. -``` - -The agent calls `search_skills(...)`; catalog hits are marked `[catalog · install_skill to add]`. Install one: - -```text -Install the most relevant materials/DFT skill you found from the catalog into this project. -``` - -The agent calls `install_skill(skill_name=...)`. - -**Verify:** +The project starts with **no external catalog synced** — that's deliberate: the +walkthrough has the agent *discover, sync, and search* it from inside the +session. Now that one `dsagt-server` owns the KB, a source the agent syncs +mid-session is **immediately searchable, no restart** — which the old two-server +split (sync in one process, search in another) couldn't do, so it had to be a +pre-`start` CLI step. + +> Plain `dsagt setup-kb` (without `--no-skill-catalog`) instead pre-syncs the +> default `scientific` source into the shared KB, which `init` then copies in. If +> you do that, step 3 below becomes an idempotent refresh — or just sync a +> different source there (e.g. `anthropic`). The `dsagt skills sync ` CLI +> still exists for scripted/headless setups. + +## Walkthrough + +Paste each prompt into Claude Code (running inside the project), one at a time. +The arc: **see what you have → find more → sync a source → install the relevant +skill → author a new one → run it.** + +### 1 — What do we have? (native discovery) +> Do you have a skill available for scaffolding new skills? Name it and give me a one-line summary of what it does. + +*Expect:* Claude names **`skill-creator`** and summarizes it — discovered +**natively, with no MCP call** and no file digging. That's the mirror working: +`dsagt start` copied the bundled `skill-creator` into `.claude/skills/`, so Claude +sees its name + description like any native skill (and loads the full `SKILL.md` +only when the skill is invoked — progressive disclosure). `search_skills` is for +the not-yet-installed *catalog* only, so it should not fire here. You confirm +*which* skills dsagt placed from a shell in step 7 (`cat .dsagt-managed.json`) — +that manifest is dsagt's internal mirror bookkeeping, not something the agent +reads. + +### 2 — Where can we find more skills? +> Where can I get more skills from? List the skill sources you can pull from and which are already synced. + +*Expect:* `list_skill_sources` → the known sources (`scientific`, `anthropic`, +`antigravity`, `composio`, `genesis`) with URLs, each flagged **available, not +synced** (nothing is synced yet on a `--no-skill-catalog` setup). + +### 3 — Sync skills from an external repo +> Sync the "scientific" source so we can search its catalog. + +*Expect:* `add_skill_source(source="scientific")` → a shallow clone of K-Dense +`scientific-agent-skills`, ~140 skills indexed into +`skills_catalog__k-dense-ai-scientific-agent-skills`, source persisted to +`dsagt_config.yaml`. Because it's one `dsagt-server`, the catalog is searchable +**immediately** — the next prompt can hit it with no restart. + +### 4 — Add the relevant skill +> Search the catalog for a skill that helps parse VASP output with pymatgen, then install the most relevant one into this project. + +*Expect:* `search_skills` (catalog hits tagged `[catalog · install_skill to add]`, +`pymatgen` at/near the top) → `install_skill(skill_name="pymatgen")`; the reply +notes it'll be native after the next start. The installed `pymatgen` skill carries +the reference docs (`pymatgen.io.vasp.Incar` / `Poscar` / `Outcar`) the converter +uses next. **Verify** the skill dir (with any `scripts/`/`references/`) landed: ```bash ls ~/dsagt-projects/isaac-skills-demo/skills/ ``` -The installed skill directory (with any `scripts/` and `references/`) is now under the project's `skills/`. +### 5 — Create the converter skill with skill-creator +This mirrors `isaac_vasp`'s `vasp-to-isaac` skill — the lightweight version reads +the small mock directory, but does the parsing with **real pymatgen**, the same +way the full workflow does (just without the heavy `vasprun.xml`). -### B. Add a second catalog source via the agent +> Use the skill-creator skill to author a new project skill named "vasp-to-isaac". Following the pymatgen skill you just installed, its converter should use `pymatgen.io.vasp` — `Incar.from_file` (ENCUT, NSW, ISPIN, LDAUU), `Poscar.from_file` (formula, atom counts), and `Outcar` (final energy, energy(sigma->0), total magnetization, max force) — to read a VASP slab calc directory and emit an ISAAC-style JSON record. The mock has no vasprun.xml, so take energy/forces from the OUTCAR. Target the shape in mock_data/expected_isaac_record.json. Save it with save_skill. -```text -Enable the "anthropic" skill source so we also have the official Anthropic skills available, then tell me how many skills that added to the catalog. -``` - -The agent calls `add_skill_source(source="anthropic")` — it clones + indexes that repo into its own catalog collection and persists the source to `dsagt_config.yaml`. Confirm: - -```text -List the skill sources currently configured and synced. -``` - -(`list_skill_sources`.) +*Expect:* the agent reads `skill-creator`'s template + the `pymatgen` skill's IO +docs, then `save_skill` writes `/skills/vasp-to-isaac/` whose script +imports `pymatgen.io.vasp` (not a hand-rolled regex parser). -### C. Author the project-specific skill with `skill-creator` +### 6 — Run the converter on the mock data +> Invoke the vasp-to-isaac skill on mock_data/mock_slab/ and write the result to audit/mock_slab_isaac.json. Then diff its structure and values against mock_data/expected_isaac_record.json and report any differences. -The real `isaac_vasp` ships a hand-written `vasp-to-isaac` converter. Here we let the agent build a mock one using the bundled meta-skill: - -```text -Use the skill-creator skill to author a new project skill named "vasp-to-isaac-mock". It should: read a mock VASP calculation directory (POSCAR + INCAR + OUTCAR) under mock_data/mock_slab/, extract the final energy, atom count, and whether it's a slab relaxation (NSW > 0), and emit a small ISAAC-style JSON record. Use mock_data/expected_isaac_record.json as the shape to target. Save it with save_skill. -``` - -The agent reads `skill-creator`'s template (`references/SKILL_template.md`) and spec, then writes `/skills/vasp-to-isaac-mock/`. - -**Verify:** - -```bash -cat ~/dsagt-projects/isaac-skills-demo/skills/vasp-to-isaac-mock/SKILL.md -``` - -### D. Run the new skill on the mock data - -```text -Invoke the vasp-to-isaac-mock workflow on mock_data/mock_slab/ and write the result to audit/mock_slab_isaac.json. Then diff its structure against mock_data/expected_isaac_record.json and report any missing fields. -``` - -### E. Inspect both tiers +*Expect:* pymatgen parses the mock dir and the agent writes +`audit/mock_slab_isaac.json` with the key fields **pymatgen extracted** — final +energy ≈ -132.8421 eV (`Outcar.final_energy`), 12 atoms (`Poscar`), ENCUT 520 / +NSW 50 (`Incar`), total mag ≈ 8.0123 (`Outcar.total_mag`) — matching the +reference. (`pymatgen` must be importable in the project env — see Notes.) +### 7 — Inspect the tiers (run in a shell) ```bash -# Installed (native-discoverable) skills — bundled + project + anything installed -dsagt skills list isaac-skills-demo - -# The native mirror Claude actually reads, plus the dsagt-managed manifest +dsagt skills list isaac-skills-demo # installed: skill-creator + pymatgen + vasp-to-isaac +dsagt skills list isaac-skills-demo --catalog # catalog: skills_catalog__k-dense-ai-scientific-agent-skills ls ~/dsagt-projects/isaac-skills-demo/.claude/skills/ cat ~/dsagt-projects/isaac-skills-demo/.claude/skills/.dsagt-managed.json ``` -The manifest lists only the skills **dsagt** placed (`skill-creator`, the installed catalog skill, `vasp-to-isaac-mock`). Any skill you hand-create under `.claude/skills/` is never touched. - -To pick up newly-mirrored skills as native `/commands`, restart Claude (`dsagt start isaac-skills-demo` again, then relaunch). +The manifest lists only the skills **dsagt** placed; any skill you hand-create +under `.claude/skills/` is never touched. Restart Claude +(`dsagt start isaac-skills-demo` again) to pick up newly-mirrored skills as +native auto-invoked skills. ## Post-Conditions -1. The KB holds per-source catalog collections (`skills_catalog__*`) for `scientific` (+ `anthropic` after Step B), searchable via `search_skills` but absent from Claude's context. -2. A catalog skill was installed into `/skills/` and mirrored into `.claude/skills/`. -3. A new `vasp-to-isaac-mock` skill, authored via `skill-creator`, exists and is native-discoverable. -4. `audit/mock_slab_isaac.json` was produced from the mock VASP directory and matches the ISAAC shape. +1. The KB holds the `skills_catalog__k-dense-ai-scientific-agent-skills` + collection, **synced in-session by the agent** (step 3), searchable via + `search_skills` but absent from Claude's context. +2. The `pymatgen` catalog skill was installed into `/skills/` and + mirrored into `.claude/skills/`. +3. A new `vasp-to-isaac` skill, authored via `skill-creator` and parsing with + **pymatgen** (`pymatgen.io.vasp`), exists and is native-discoverable. +4. `audit/mock_slab_isaac.json` was produced from the mock VASP directory by + pymatgen and matches the ISAAC shape + values. 5. `.claude/skills/.dsagt-managed.json` tracks exactly the dsagt-placed skills. ## Cleanup @@ -150,10 +159,32 @@ dsagt stop isaac-skills-demo dsagt rm isaac-skills-demo # add -y to skip the prompt ``` -The shared catalog cache lives at `~/dsagt-projects/.skill_sources/` and is reused across projects; delete it to force a fresh clone on the next `setup-kb` / `add_skill_source`. +The shared catalog cache lives at `~/dsagt-projects/.skill_sources/` and is +reused across projects; delete it to force a fresh clone. ## Notes -- `mock_data/` is intentionally tiny and **not** real DFT output — the OUTCAR is a truncated stub. It exists only to exercise the conversion skill's parse-and-emit path. -- If your embedding backend isn't configured, `search_skills` degrades to the "requires a configured knowledge base" message; `install_skill` and the native mirror still work (they're pure filesystem operations). -- Swap in other catalogs the same way: `dsagt skills add isaac-skills-demo antigravity` (or `composio`, or any `https://github.com/owner/repo`). +- **pymatgen must be importable** in the project env to run step 6 (the converter + uses `pymatgen.io.vasp`, exactly as `isaac_vasp` does). Install it once — + `pip install pymatgen` (or `uv pip install pymatgen`) into the same environment + `dsagt` runs in; the installed `pymatgen` skill's `references/` document this. + This is the demo's one real dependency — "lightweight" is about the data, not + avoiding pymatgen. +- `mock_data/` is intentionally tiny and **not** real DFT output, but it is *valid + VASP format*: the INCAR/POSCAR parse cleanly, and the OUTCAR stub keeps exactly + the lines pymatgen's `Outcar` reads (TOTEN, `energy(sigma->0)`, magnetization, + the force block) while omitting the ~250k-line SCF/eigenvalue blocks. No + `vasprun.xml` (it'd be large), so the converter takes energy/forces from OUTCAR. +- With the default **local** embedder (`bge-small`), absolute search scores are + low (~0.03) because short queries under-score long SKILL.md text — *ranking* is + still correct (pymatgen #1). Switch `embedding.backend` to `api` for sharper + relevance. With no embedder at all, `search_skills` falls back to keyword + scoring; `install_skill` and the native mirror are pure filesystem ops. +- Add **more** sources the same way, in-session or from a shell — e.g. ask the + agent to "enable the anthropic source", or run + `dsagt skills add isaac-skills-demo antigravity` (or `composio`, `genesis`, or + any `https://github.com/owner/repo`). Each lands in its own + `skills_catalog__*` collection. +- Sister demo: [`genesis_skills`](../genesis_skills/) flexes the same catalog → + install → native loop plus KB domain ingest and datacard generation, against + the Genesis (OSTI GitLab) source. From a31a413e64cddf281f01fbcc2e9fa5977a06e387 Mon Sep 17 00:00:00 2001 From: aarontuor Date: Sun, 28 Jun 2026 20:51:18 -0700 Subject: [PATCH 17/44] feat: proxy-free trace pipeline + episodic memory (Phase 2/3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recover observability and episodic memory without the LiteLLM proxy, by reading each agent's own on-disk session transcript instead of intercepting LLM traffic — no proxy, no credentials, serverless trace store. Trace pipeline (Phase 2), all in one src/dsagt/traces.py: - `Trace` — the canonical session form as nested dicts (a list of span dicts + compose/query/to_exchanges methods; the span/message/block shapes have a single home in Trace's add_* methods). - `Reader`/`Translator` ABCs with one subclass per agent (claude/codex/goose/ opencode/cline); codex/goose/opencode/cline share the Translator turn- template, claude is bespoke, claude+codex share JsonlReader. - `TraceCollector` — the in-session heartbeat: read → translate → hand the Trace to its consumers, each with its own ack set for idempotency. - `MLflowSink` (observability) consumes Trace + dict spans directly. Episodic memory (Phase 3, opt-in via `dsagt init --episodic`): - `memory.MemoryExtractor` — a TraceCollector consumer: Tier-0 mechanical chunk+tag+embed always; Tier-1 distillation via `judge.LocalJudge` (Qwen2.5-1.5B GGUF, GBNF-grammar JSON), degrading to Tier-0 on failure. - Recency-weighted retrieval over session_memory (episodic.recency_half_life_days). - `judge.py` — Judge.create → LocalJudge (default) / APIJudge; llama-cpp-python added to core, pinned to an integrity-verified CPU wheel. Tool-use indexing: - `provenance.ToolUseIndexer` — incremental, idempotent embedding of dsagt-run records into the tool_use collection on the heartbeat + before reconstruct_pipeline (fixes the prior re-index-everything duplicate bug). Removed: the LiteLLM proxy (commands/proxy_server.py, test_proxy_callback, proxy_walkthrough) and the roo agent. Docs (README, docs/ site, CHANGELOG) updated to the post-proxy, serverless, episodic-enabled reality. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 45 + CLAUDE.md | 88 +- NOTICE | 12 + README.md | 148 +- design-notes/genesis-skills-comparison.md | 516 ----- design-notes/skills-catalog-server-merge.md | 177 -- developer.md | 34 +- docs/architecture.md | 18 +- docs/cli.md | 46 +- docs/developer.md | 33 +- docs/index.md | 12 +- docs/knowledge-base.md | 37 +- docs/mcp-servers.md | 4 +- docs/observability.md | 42 +- docs/quickstart.md | 71 +- docs/tools-skills.md | 4 +- pyproject.toml | 93 +- src/dsagt/__init__.py | 3 +- src/dsagt/agents/__init__.py | 240 +- src/dsagt/agents/base.py | 442 +--- src/dsagt/agents/claude.py | 136 +- src/dsagt/agents/cline.py | 216 +- src/dsagt/agents/codex.py | 157 +- src/dsagt/agents/goose.py | 110 +- src/dsagt/agents/opencode.py | 104 +- src/dsagt/agents/roo.py | 263 --- src/dsagt/commands/cli.py | 1336 ++++------- src/dsagt/commands/info.py | 185 +- src/dsagt/commands/proxy_server.py | 265 --- src/dsagt/commands/run_tool.py | 26 +- src/dsagt/commands/setup_core_kb.py | 511 +++-- src/dsagt/judge.py | 310 +++ src/dsagt/knowledge.py | 1952 +++++++++-------- src/dsagt/mcp/knowledge_tools.py | 231 +- src/dsagt/mcp/memory_tools.py | 34 +- src/dsagt/mcp/registry_tools.py | 14 +- src/dsagt/mcp/server.py | 262 ++- src/dsagt/mcp/skill_tools.py | 4 +- src/dsagt/memory.py | 619 +++--- src/dsagt/observability.py | 780 ++----- src/dsagt/provenance.py | 240 +- src/dsagt/registry.py | 21 +- src/dsagt/session.py | 1029 ++++----- src/dsagt/skills.py | 111 +- src/dsagt/tools/scan_directory.py | 59 +- src/dsagt/traces.py | 1281 +++++++++++ tests/conftest.py | 47 +- tests/mcp_helpers.py | 73 +- tests/smoke_test/greet.py | 5 +- .../smoke_test/manual_runs/cli_walkthrough.md | 2 +- .../manual_runs/proxy_walkthrough.md | 109 - .../manual_runs/vscode_walkthrough.md | 2 +- tests/smoke_test/run.sh | 113 +- tests/test_chroma_metadata.py | 171 +- tests/test_config.py | 1254 ++++------- tests/test_dependency_integration.py | 5 +- tests/test_dsagt_server.py | 23 +- tests/test_episodic_integration.py | 122 ++ tests/test_extraction.py | 287 +-- tests/test_info.py | 284 ++- tests/test_integration.py | 94 +- tests/test_judge.py | 124 ++ tests/test_kb_search_filters.py | 226 +- tests/test_knowledge_base.py | 774 +++---- tests/test_knowledge_integration.py | 76 +- tests/test_knowledge_server.py | 104 +- tests/test_memory_extractor.py | 102 + tests/test_observability.py | 192 +- tests/test_outlier.py | 45 +- tests/test_pipeline.py | 122 +- tests/test_proxy_callback.py | 226 -- tests/test_recency.py | 69 + tests/test_registry_server.py | 1 - tests/test_run.py | 182 +- tests/test_server_startup.py | 45 +- tests/test_setup_core_kb.py | 95 +- tests/test_site_config.yaml.example | 5 +- tests/test_skill_discovery.py | 12 +- tests/test_skill_tools.py | 9 +- tests/test_skills_catalog.py | 6 +- tests/test_tool_executions.py | 173 +- tests/test_trace_pipeline.py | 224 ++ tests/test_trace_scan.py | 236 ++ tests/test_traces.py | 436 ++++ tests/test_traces_cline.py | 169 ++ tests/test_traces_codex.py | 212 ++ tests/test_traces_goose.py | 167 ++ tests/test_traces_opencode.py | 170 ++ 88 files changed, 9297 insertions(+), 9817 deletions(-) create mode 100644 NOTICE delete mode 100644 design-notes/genesis-skills-comparison.md delete mode 100644 design-notes/skills-catalog-server-merge.md delete mode 100644 src/dsagt/agents/roo.py delete mode 100644 src/dsagt/commands/proxy_server.py create mode 100644 src/dsagt/judge.py create mode 100644 src/dsagt/traces.py delete mode 100644 tests/smoke_test/manual_runs/proxy_walkthrough.md create mode 100644 tests/test_episodic_integration.py create mode 100644 tests/test_judge.py create mode 100644 tests/test_memory_extractor.py delete mode 100644 tests/test_proxy_callback.py create mode 100644 tests/test_recency.py create mode 100644 tests/test_trace_pipeline.py create mode 100644 tests/test_trace_scan.py create mode 100644 tests/test_traces.py create mode 100644 tests/test_traces_cline.py create mode 100644 tests/test_traces_codex.py create mode 100644 tests/test_traces_goose.py create mode 100644 tests/test_traces_opencode.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 89cef74..169cbe4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,51 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +This cycle restores **observability and episodic memory without a proxy** — both +recovered by reading each agent's own on-disk session transcript instead of +intercepting its LLM traffic — and ships episodic memory end to end. + +### Added +- **Trace pipeline + observability (proxy-free).** An in-session heartbeat in + `dsagt-server` reads the agent's transcript, translates it to a canonical + trace shape, and logs it to the serverless MLflow store — so prompts, + responses, tool calls, and token usage land in the trace view for **every** + supported agent (claude, codex, goose, opencode, cline), with no proxy, no + OTel routing, and no credentials. DSAgt's own `kb.*` / `tool.execute` / + registry spans flow to the same store. +- **Episodic memory (opt-in).** `dsagt init --episodic` (with optional + `--domain-tags "a,b"`) turns on automatic session memory: the heartbeat + distills each completed turn into a few tagged, ≤1-sentence facts in the + per-project `session_memory` collection, retrievable via + `kb_search` / `kb_get_memories`. Two tiers — **Tier-1** uses a small **local** + LLM judge (`Qwen2.5-1.5B`, grammar-constrained JSON; no API key, ~1 GB GGUF + downloaded on first use), **Tier-0** is a mechanical chunk+tag+embed fallback + used automatically if the judge fails, so a turn is never lost. +- **Recency-weighted episodic retrieval** (`episodic.recency_half_life_days`, + default 14): a newer fact edges out a stale one as a bounded boost, so a + corrected fact wins without contradiction detection while durable old facts + keep their relevance. +- `llama-cpp-python` as a core dependency for the local judge, pinned and + installed from a prebuilt CPU wheel index so a plain `uv sync` never compiles + llama.cpp (GPU on CUDA HPC is an opt-in reinstall). + +### Changed +- **Tool-use indexing is now incremental.** `dsagt-run` execution records are + embedded into the `tool_use` collection by the heartbeat as a session runs + (idempotent), and on demand right before `reconstruct_pipeline` — so the + pipeline review and tool-use search reflect the calls just made, instead of + waiting for the next session's startup catch-up. + +### Fixed +- **Duplicate `tool_use` entries.** Startup catch-up re-indexed the entire + `trace_archive` every launch (the idempotency cursor was written but never + read), accumulating duplicates each session. Indexing is now idempotent + against a persisted ack set shared by the heartbeat and catch-up. + +### Removed +- Dead `provenance.index_execution_record` (the orphaned single-record path, + superseded by the heartbeat's batched, idempotent indexer). + ## [0.2.0] - 2026-06-24 This release adds an **external skill-catalog system** and consolidates the diff --git a/CLAUDE.md b/CLAUDE.md index 8fbfcb9..ecb1742 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,12 +4,18 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -DSAGT (DataSmith Agent) is an AI-assisted data pipeline builder that exposes MCP (Model Context Protocol) servers to agent platforms (Claude Code, Goose, Roo, Cline, Codex). It helps domain scientists create reproducible, auditable data curation pipelines through iterative, knowledge-driven tool generation. +DSAGT (DataSmith Agent) is an AI-assisted data pipeline builder that exposes an MCP (Model Context Protocol) server to agent platforms (Claude Code, Goose, Roo, Cline, Codex, opencode). It helps domain scientists create reproducible, auditable data curation pipelines through iterative, knowledge-driven tool generation. -## Two run modes +## Run model (BYOA, serverless) -1. **BYOA (Bring Your Own Agent)** — default for everyday use. `dsagt init --agent ` writes per-agent MCP config artifacts; `dsagt mlflow ` backgrounds MLflow and prints the OTel routing exports the user pastes into the shell that runs `claude` / `goose` / etc. Project / agent / session_id are read from `/dsagt_config.yaml` + `.runtime` (single source of truth, no env-var duplication). `dsagt memory --project X` extracts episodic memory from accumulated traces — but only from proxy-shape traces (see #2). -2. **Proxy mode** — `dsagt start --enable-proxy ` interposes a LiteLLM proxy between the agent and its LLM provider. The proxy autologs every LLM call into MLflow with `mlflow.spanInputs` / `mlflow.spanOutputs` populated, which is the only trace shape `dsagt memory` knows how to extract from. Use this when you want both (a) request/response columns populated in the MLflow UI and (b) episodic memory extraction. Native agent OTel emission (Claude Code, Goose) is visible in the UI but uses a different shape (`api_response_body` log events), so memory extraction skips those traces. +DSAGT is **BYOA (Bring Your Own Agent)**: the agent talks to its own LLM provider directly — DSAGT never interposes on its network traffic (the LiteLLM proxy was removed). `dsagt init --agent ` writes the per-agent instructions file + MCP config and nothing to paste (no launch shim, no OTel/env instructions). Two supported, behaviorally-identical start flows: + +1. **Bare launch** — start the agent from the project dir (`cd && claude` / `goose` / …). +2. **`dsagt start `** — spawns the agent for you in the foreground and, on exit, runs post-session `run_extraction`. Its real job is owning a reliable session-end trigger. + +The MCP server is self-sufficient: it derives the project from its cwd (`dsagt_config.yaml`), with the MCP-config env block as robustness, and behaves identically regardless of how it was launched. + +**Serverless store.** Self-logging (MCP-server + `dsagt-run` spans, and Claude's `mlflow autolog` Stop hook) goes to a `sqlite:////mlflow.db` MLflow store — **no server to run** (SQLite is MLflow's supported serverless backend; the filesystem `./mlruns` store is deprecated). The resolver (`observability.resolve_tracking_uri`) order is `MLFLOW_TRACKING_URI` env → config → that sqlite default, and never raises. The DB auto-creates/migrates on first span; DSAGT emits only traces, so no artifact dir materializes. View traces with `mlflow ui --backend-store-uri sqlite:////mlflow.db`. DSAGT writes **no** telemetry env on the agent (no forced OTel) — agent LLM-call history is recovered post-hoc from the on-disk transcript (Phase 2's pipeline), not by intercepting traffic. ## Commands @@ -22,51 +28,72 @@ uv run ruff check . # lint **`python -m pytest` not bare `pytest`.** The bare-binary form picks up the wrong pytest on this machine and crashes with `ModuleNotFoundError: dsagt`. -**Don't run the full suite by default.** ~50s for 547 tests is too slow for an iteration loop. Run only the test file relevant to the change. `tests/test_config.py` covers session, init, agents, BYOA hints, launch shim, and memory state. Skip `test_integration.py`, `test_*_integration.py`, `test_server_startup.py`, `test_dependency_integration.py` unless explicitly relevant — they hit network or spawn subprocesses. +**Don't run the full suite by default.** ~50s for ~590 tests is too slow for an iteration loop. Run only the test file relevant to the change. `tests/test_config.py` covers session, init, agents, BYOA hints, the serverless store resolution, and the no-launch-shim/no-OTel invariants. Skip `test_integration.py`, `test_*_integration.py`, `test_server_startup.py`, `test_dependency_integration.py` unless explicitly relevant — they hit network or spawn subprocesses. ## Code Organization The codebase separates **commands** (entry points with argparse, launched as CLI tools or subprocesses) from **modules** (importable logic). Commands live in `src/dsagt/commands/`, modules live in `src/dsagt/`. **Commands** (`src/dsagt/commands/`): -- `cli.py` — `dsagt init / mlflow / memory / info / list / mv / rm / setup-kb / smoke-test / stop / start` (user-facing CLI). `dsagt start --enable-proxy` activates proxy mode; without `--enable-proxy` it's the supervised BYOA equivalent (start MLflow + agent under one process tree). -- `proxy_server.py` — `dsagt-proxy` (LiteLLM proxy with OTel autolog). Spawned by `dsagt start --enable-proxy`. +- `cli.py` — `dsagt init / start / info / list / mv / rm / smoke-test` (user-facing CLI). `dsagt start` launches the agent in the foreground and runs post-session extraction on exit. - `run_tool.py` — `dsagt-run` (tool execution wrapper). -- `setup_core_kb.py` — core KB setup (called via `dsagt setup-kb`). -- `info.py` — `dsagt info` (project / config introspection). +- `setup_core_kb.py` — KB-asset build engine (`resolve_assets` / `ensure_assets`), called by `dsagt init` to provision the shared KB; not a CLI command of its own. +- `info.py` — `dsagt info` (project / config introspection + trace triage). (The MCP server — `dsagt-server` — lives in the `src/dsagt/mcp/` package, see below.) **Modules** (`src/dsagt/`): -- `session.py` — Project init, agent config generation, env-var resolution, config load/validate, service start/stop, end-of-session memory extraction orchestration. -- `agents/` — Per-agent-platform setup (`base.py` ABC + `claude.py` / `goose.py` / `cline.py` / `roo.py` / `codex.py`). Each subclass owns its `write_static`, `write_dynamic`, `env_overrides`, `byoa_env_hints`, `launch_oneliner`. Shared helpers (`_mcp_env_block`, `_render_launch_shim`, `_build_mcp_servers_dict`) in `base.py`. -- `knowledge.py` — FAISS/ChromaDB document retrieval, embedding backends, per-collection routing. +- `session.py` — Project init, agent config generation, env-var resolution, config load/validate, session-id minting (`new_session_id`), end-of-session extraction orchestration (`run_extraction`). No service supervision — the store is serverless. +- `agents/` — Per-agent-platform setup (`base.py` ABC + `claude.py` / `goose.py` / `cline.py` / `roo.py` / `codex.py` / `opencode.py`). Each subclass owns its `write_static`, `write_dynamic`, `runtime_env` (per-project state dirs only), `byoa_env_hints`, `vscode_hint`. Shared helpers (`_mcp_env_block`, `_build_mcp_servers_dict`) in `base.py`. DSAGT sets no telemetry/OTel env and writes no launch shim. +- `knowledge.py` — ChromaDB document retrieval, embedding backends, per-collection routing (FAISS removed). - `registry.py` — `ToolRegistry` (CLI tools) + `SkillRegistry` (agent instruction skills), KB indexing. - `provenance.py` — Tool execution records (`run_and_record`, `ToolRecordStore`), execution-record indexing into ChromaDB, pipeline reconstruction (`reconstruct_pipeline`, dependency graph). -- `observability.py` — MLflow / OTel tracing setup, `init_tracing`, span helpers. -- `memory.py` — Explicit memory (YAML), episodic-memory extraction prompt + LLM call, outlier detection, `extract_session`. +- `observability.py` — MLflow tracing setup over the serverless file store; `resolve_tracking_uri` (never-raise), `init_tracing`, span helpers. +- `memory.py` — Explicit memory (YAML, `ExplicitMemory`) + the episodic `MemoryExtractor` (a `traces.TraceCollector` consumer: Tier-0 mechanical chunk+tag+embed always, Tier-1 `judge.Judge` distillation when enabled, falling back to Tier-0 on judge failure) + outlier-suggestion queue (`SuggestionQueue`). Facts carry `ts_epoch` for recency-weighted retrieval. `extract_session` remains a no-op stub kept only for the deferred cross-session N+1 catch-up call site — the heartbeat consumer is the live episodic path. The LLM judge lives in `judge.py`. - `skills.py` — External skill catalog data plane (`SkillsCatalog`: clone/sync/index/install), the `SkillRouter` render facade, and the Genesis-derived keyword scorer (`rank_skills`). +- `traces.py` — the whole trace pipeline in one module: the pure-data `Trace` (a list of span dicts + compose/query/`to_exchanges` methods; the span/message/block shapes have one home, `Trace`'s `add_*` methods), the `Reader`/`Translator` ABCs with a per-agent subclass each (Claude bespoke; codex/goose/opencode/cline share the `Translator` turn-template; claude+codex share `JsonlReader`), and `TraceCollector` — the MCP-server heartbeat that reads→translates→hands the `Trace` to its consumers (the MLflow logger, the memory distiller), each with its own ack set for idempotency. Imports nothing heavy (mlflow is lazy, consumer-side). +- `judge.py` — the episodic Tier-1 `Judge` (`Judge.create` → `LocalJudge` GGUF default / `APIJudge` stub) + the lean per-turn distill prompt/parser. Local-by-default (no API key); `distill` is grammar-constrained on `LocalJudge`, a no-op on `APIJudge`. **MCP server** (`src/dsagt/mcp/`) — the single merged `dsagt-server`. `server.py` owns `main()`, the shared-KB startup (`_build_kb_from_config`), and the dispatch shell (`build_dispatch_server`); the tool surface is split by concern across `registry_tools.py` (tool registry + execution + provenance, 8 tools), `knowledge_tools.py` (KB retrieval, 6), `memory_tools.py` (explicit memory + suggestions, 4), and `skill_tools.py` (skill search/install/sources, 5). Each `*_tools.py` exposes a `_*_tools_and_handlers()` factory (composed by `create_dsagt_server`) plus a `create_*_server` test wrapper. -Entry points are defined in `pyproject.toml` `[project.scripts]`: the CLI/proxy/run/setup-kb tools point to `dsagt.commands.*:main`, and `dsagt-server` points to `dsagt.mcp.server:main`. +Entry points (`pyproject.toml` `[project.scripts]`): `dsagt` → `dsagt.commands.cli:main`, `dsagt-run` → `dsagt.commands.run_tool:main`, `dsagt-server` → `dsagt.mcp.server:main`. **Bundled assets** (shipped as `package-data` in `pyproject.toml`): - `src/dsagt/tools/` — built-in tool specs (markdown + YAML frontmatter) copied into new projects. -- `src/dsagt/skills/` — built-in skills (e.g., `datacard-generator`) the agent discovers via `search_skills`. +- `src/dsagt/skills/` — built-in skills (e.g., `skill-creator`) the agent discovers via `search_skills`. - `src/dsagt/dsagt_instructions.md` — agent-platform-agnostic system instructions injected into per-agent files at init. -**`use_cases/`** holds end-to-end domain walkthroughs (`microbial_isolates/`, `cryoem/`, `isaac_vasp/`). They are reference material for users, not part of the test suite. `isaac_vasp/` is currently in active development on this branch. +**`use_cases/`** holds end-to-end domain walkthroughs (`microbial_isolates/`, `cryoem/`, `isaac_vasp/`). They are reference material for users, not part of the test suite. + +## Code style & conventions + +Distilled from working on this codebase; `knowledge.py` is the reference example of the house style. + +**No defensive swallowing.** Don't add guards that silently absorb empty/invalid input (`if not texts: return []`, empty-array short-circuits, disk-state "reconciliation" of can't-happen states). They convert a caller's bug into a silent success you'll never see. Empty/invalid input is out-of-contract — let it surface. Translating a *real, reachable* exception into an actionable message (e.g. a dim-mismatch hint) is different and welcome; swallowing is not. + +**YAGNI / no speculative generality.** Don't add a flag or option for a path never exercised in practice. Model real variation *structurally* (a subclass / distinct type), not with a runtime toggle nothing flips — e.g. the local store is unconditionally hybrid; "dense-only" is a future store *type*, not a `hybrid=False` flag. Don't extract a base class until a second concrete impl forces the seam (you can't validate the abstraction with one impl). This is dev-stage: gut cleanly, no back-compat shims, aim net-minus LOC. + +**Explicit named arguments, never `**kwargs` config-splat.** A dict of kwargs threaded through layers and `**`-splatted into a constructor hides what's actually passed. Use explicit named params; unpack any config dict at the boundary (e.g. `Embedder.create(backend, *, model=, base_url=, ...)`, callers pass `model=cfg.get("model")`). + +**Put behavior where it belongs.** Factories live on the class they build (`Embedder.create` classmethod, not a free `_make_embedder`). Trivial field getters are unpythonic — expose the attribute; but a *method* is right when access does real work (lazy I/O + memoization like `_get_bm25` — the name signals cost a bare subscript would hide). Pure, stateless algorithms shared by multiple classes stay module-level functions (RRF: `_rrf_merge`/`_rrf_across`), not staticmethods nailed to one arbitrary owner. + +**Comments state the real reason, at the point they explain it.** A lazy import is justified *at the import site* with its actual cause, not as an "this is absent" note in the import block citing a stale rationale. If the reason changes, fix the comment. + +**Import hygiene on hot paths.** Modules on frequently-invoked paths (`dsagt-run` runs per tool call) must not transitively drag heavy modules in for *annotation-only* type hints. Use `from __future__ import annotations` + a `TYPE_CHECKING`-guarded import (verify the module doesn't introspect annotations at runtime first). Keep cold start lean; lazy-import the heavy leaf (llama_index) at its single use site. + +**Naming.** Prefer concise domain names (`APIEmbedder`/`LocalEmbedder`, not `…EmbeddingClient`). + +**Module docstrings (major modules).** Open with a title line + 3–5 sentences: what the module does, the capabilities it backs, and the design motivations. Follow it with an **ASCII-art UML class map** — one consistent notation throughout (`knowledge.py` uses `◇` holds · `◆` owns · `▷` inherits, every edge drawn the same way). Treat the class-map diagram as a deliverable of any **major module refactor** — add or refresh it whenever the class structure changes substantially. ## BYOA artifacts `dsagt init --agent X --location ` writes (in the project dir): -- `dsagt_config.yaml` — internal config (project name, agent, mlflow port pinned at init, embedding/knowledge/extraction settings). No user-facing fields, no credentials. +- `dsagt_config.yaml` — internal config (project name, agent, embedding/knowledge/extraction/skills settings). No mlflow port (the store is the serverless `sqlite:////mlflow.db`), no user-facing fields, no credentials. - Per-agent instructions file (e.g., `CLAUDE.md`, `.goosehints`, `AGENTS.md`). -- Per-agent MCP config artifact (`.mcp.json` for claude, `goose.yaml` for goose, `cline_mcp_settings.json` via `cline mcp add`, `.roo/mcp.json`, `.codex-data/config.toml`). All include the env block (DSAGT_PROJECT, DSAGT_PROJECT_DIR, MLFLOW_TRACKING_URI, EMBEDDING_*) so MCP-server children that don't inherit shell env still log to the right MLflow. -- `dsagt-launch.sh` — bash shim that exports all dsagt-internal env (DSAGT_*, MLFLOW_*, OTEL_*, agent-specific telemetry verbosity flags), resolves the OTel experiment-id header at run time via curl, then execs the agent. The user runs this directly to launch. +- Per-agent MCP config artifact (`.mcp.json` for claude, `goose.yaml` for goose, `cline_mcp_settings.json` via `cline mcp add`, `.roo/mcp.json`, `.codex-data/config.toml`). The env block carries benign routing only (DSAGT_PROJECT, DSAGT_PROJECT_DIR, DSAGT_SESSION_ID, MLFLOW_TRACKING_URI, EMBEDDING_*) so MCP-server children of agents that don't inherit shell env (codex/cline/roo) still log to the right store. No credentials, no OTel routing, no privacy overrides. +- For claude, `.claude/settings.json` wiring the `mlflow autolog claude` Stop hook (transcript → file store at session end). -`dsagt memory --project X` tracks a high-water-mark in `/.dsagt/extracted_at.json` so re-runs only process new traces. +No launch shim is written and `dsagt init` prints no env/OTel instructions — the user starts the agent directly or via `dsagt start`. ## Architecture @@ -75,30 +102,32 @@ Entry points are defined in `pyproject.toml` `[project.scripts]`: the CLI/proxy/ A single merged `dsagt-server` (`src/dsagt/mcp/`) exposes 23 tools across four concern modules under one `Server` + one shared `KnowledgeBase`: 1. **Registry tools** (`mcp/registry_tools.py` + `registry.py` / `provenance.py`) — tool analysis, registration, dependency installation, command/file/http execution, and pipeline reconstruction. Tools are saved as markdown specs with YAML frontmatter. -2. **Knowledge tools** (`mcp/knowledge_tools.py` + `knowledge.py`) — semantic search over document collections (FAISS + ChromaDB, optional cross-encoder reranking); long ops run as background jobs. +2. **Knowledge tools** (`mcp/knowledge_tools.py` + `knowledge.py`) — semantic search over document collections (ChromaDB, optional cross-encoder reranking); long ops run as background jobs. 3. **Memory tools** (`mcp/memory_tools.py` + `memory.py`) — explicit memory + outlier suggestions (`kb_remember` / `kb_get_memories` / …). 4. **Skill tools** (`mcp/skill_tools.py` + `skills.py`) — skill search / install + external catalog sources. ### Observability -- **MLflow** — Token usage, cost, latency, full LLM-call traces via OTel. Started by `dsagt mlflow ` (foreground, in its own terminal). Port is pinned at init time and lives in `dsagt_config.yaml`. -- **dsagt-run** (`commands/run_tool.py` + `provenance.py`) — Wraps tool commands; captures execution layer (command, stdout/stderr, timing, file lists) into `trace_archive/`. -- **MCP-server OTel** — `dsagt-server` calls `init_tracing()` at startup; its tool spans (kb.*, registry.*) flow to MLflow alongside the agent's LLM-call spans. +- **Serverless MLflow store** — Spans land in `sqlite:////mlflow.db` (no server). View with `mlflow ui --backend-store-uri sqlite:////mlflow.db`. The tracking URI resolves via `observability.resolve_tracking_uri` (env → config → sqlite default; never raises). +- **dsagt-run** (`commands/run_tool.py` + `provenance.py`) — Wraps tool commands; captures execution layer (command, stdout/stderr, timing, file lists) into `trace_archive/`, and emits `tool.execute` spans. +- **MCP-server spans** — `dsagt-server` calls `init_tracing()` at startup; its tool spans (kb.*, registry.*) flow to the file store. Session correlation via `DSAGT_SESSION_ID` (minted by `dsagt start`). +- **Agent traces** — recovered post-hoc from the on-disk transcript, not native OTel. Today only claude's `mlflow autolog` Stop hook populates agent traces; the general transcript pipeline is Phase 2. ### Memory System -- **Episodic memory** (`memory.py:extract_session`) — End-of-session LLM extraction of facts from MLflow traces into ChromaDB, with per-category outlier detection via embedding centroids. Triggered by `dsagt memory --project X`. -- **Explicit memory** (`memory.py:ExplicitMemory`) — User-confirmed facts in YAML, loaded into agent context at session start. +- **Explicit memory** (`memory.py:ExplicitMemory`) — User-confirmed facts in YAML, loaded into agent context at session start via the `kb_remember` / `kb_get_memories` MCP tools (the vector mirror is optional — degrades to pure-YAML if the store is down). +- **Tool-execution indexing** — `provenance.ToolUseIndexer` embeds `trace_archive/` records into the project's `tool_use` collection incrementally on the MCP-server heartbeat (idempotent via a persisted ack set), plus a startup catch-up and an on-demand tick before `reconstruct_pipeline`. No LLM, no credentials. +- **Episodic memory** — live, **opt-in** (`episodic.enabled`, via `dsagt init --episodic`). The `memory.MemoryExtractor` consumer consumes `Trace.to_exchanges()` on the heartbeat: Tier-0 mechanical (chunk+tag+embed, no LLM) always; Tier-1 distillation via `judge.LocalJudge` (Qwen2.5-1.5B GGUF, GBNF-grammar JSON) when a judge backend is configured, degrading to Tier-0 on failure. Retrieval is recency-weighted (`episodic.recency_half_life_days`). `extract_session` stays a no-op stub for the deferred N+1 catch-up only. There is no user-facing `dsagt memory` command. ### Key Design Patterns - **Agent-agnostic**: DSAGT is infrastructure, not an agent. Capabilities are MCP services. -- **Session isolation**: Each project gets its own directory with config, tools, skills, kb_index, trace_archive, mlflow data. +- **Session isolation**: Each project gets its own directory with config, tools, skills, kb_index, trace_archive, and the `mlflow.db` sqlite trace store. - **Tools vs Skills**: Tools are CLI executables in `/tools/` (specs with parameters, wrapped by dsagt-run). Skills are agent instruction workflows in `/skills/` (SKILL.md + reference docs). Both are discoverable via ChromaDB-backed semantic search. ## DSAGT Pipeline Builder Workflow -When acting as a pipeline builder (using the MCP servers), follow these constraints: +When acting as a pipeline builder (using the MCP server), follow these constraints: 1. **Never directly access data** — all data operations go through registered tools. 2. **Tool preference hierarchy**: Registered tool → KB package tool → Custom implementation. @@ -113,4 +142,3 @@ When acting as a pipeline builder (using the MCP servers), follow these constrai - Async tests for server handlers. - Temp directories for isolation; the `_use_tmp_registry` fixture in `tests/test_config.py` patches `DEFAULT_PROJECTS_BASE` and the project registry to `tmp_path`. - Integration tests in `test_*_integration.py` require real `EMBEDDING_*` / `LLM_*` credentials. -- A handful of tests are class-skipped under `TestProviderEnvInjection` with a long reason about "old-code-shape env_overrides" — those describe the pre-Phase-1 design where `env_overrides` did broad provider-credential translation, which is now narrower (model env-var pinning only; provider creds + base URLs come via `proxy_env_overrides` or per-agent config files). Kept around for reference; safe to delete once Phase 2 is stable on real workloads. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..a5f3c99 --- /dev/null +++ b/NOTICE @@ -0,0 +1,12 @@ +DSAGT (DataSmith Agent) +Copyright 2026 the DSAGT authors + +This product includes software developed at Databricks, Inc. + +Portions of src/dsagt/traces.py (the Claude transcript-parsing grammar — last +user-message selection, per-turn input windowing, and next-timestamp span +durations) are ported and adapted from MLflow's mlflow/claude_code/tracing.py. + + MLflow — Copyright (c) Databricks, Inc. + Licensed under the Apache License, Version 2.0. + https://github.com/mlflow/mlflow diff --git a/README.md b/README.md index c73984b..2af1c79 100644 --- a/README.md +++ b/README.md @@ -33,20 +33,22 @@ pip install "git+https://github.com/AI-ModCon/dsagt.git" dsagt --version # 0.2.0 ``` -This puts the `dsagt` CLI (and the `dsagt-run` / `dsagt-server` helpers) on your PATH. Then build the shared knowledge base once and create your first project: +This puts the `dsagt` CLI (and the `dsagt-run` / `dsagt-server` helpers) on your PATH. Create your first project — `dsagt init` is interactive (it walks you through the agent platform, project location, packaged knowledge collections, and skill-catalog sources) and provisions the shared knowledge base on first run: ```bash -dsagt setup-kb # bundled tools + skills + reference corpora - # (downloads a ~130 MB local embedder on first run) -dsagt init my-project --agent claude # or: goose / codex / opencode / roo / cline -dsagt start my-project +dsagt init # interactive; pick agent, collections, sources ``` -To upgrade later, reinstall and re-run `setup-kb` to pick up new bundled tools/skills: +Then start your agent from the project directory, use dsagt cli, or open a VS code : + +```bash +cd ~/dsagt-projects/my-project && claude # …or: dsagt start my-project +``` + +To upgrade later, reinstall — re-running `dsagt init my-project` reconfigures an existing project in place: ```bash pip install --upgrade "git+https://github.com/AI-ModCon/dsagt.git" -dsagt setup-kb ``` > Pin to a specific release once tags are published, e.g. `pip install "git+https://github.com/AI-ModCon/dsagt.git@v0.2.0"`. @@ -58,29 +60,26 @@ Clone the repo and use `uv` (editable install with the full test suite) — see ## Quick Start -Explore DSAgt knowledge ingest, tool registration, provenance, and explicit memory using the mock project in [`tests/smoke_test/`](tests/smoke_test/). Uses `claude`; substitute another agent (`goose` / `codex` / `opencode`) if you prefer — the prompts are agent-agnostic. +Explore DSAgt knowledge ingest, tool registration, provenance, and explicit memory using the mock project in [`tests/smoke_test/`](tests/smoke_test/). Uses `claude`; substitute another agent (`goose` / `codex` / `opencode` / `roo` / `cline`) if you prefer — the prompts are agent-agnostic. ```bash -# 0. Installation +# 0. Install git clone https://github.com/AI-ModCon/dsagt.git cd dsagt uv sync # add --all-groups for the test suite source .venv/bin/activate # so `dsagt` is on PATH -# Set convenience folder env variable for quickstart demo (not a normal dsagt step) +# A convenience variable for the demo paths below (not a normal dsagt step) export SMOKE_DIR="$(pwd)/tests/smoke_test" -# 1. Create a new project called quickstart +# 1. Create a project called quickstart. Interactive `dsagt init` prompts for +# the agent, location, knowledge collections, and skill sources, and +# provisions the shared knowledge base on first run (a ~130 MB local +# embedder downloads once). `--agent` makes it non-interactive: dsagt init quickstart --agent claude -# 2. Start MLflow in the background (writes /mlflow.log) and print -# the OTel routing exports for this session, including the resolved -# experiment id: -dsagt mlflow quickstart - -# 3. Paste the export block dsagt mlflow printed into THIS shell, then -# launch claude from the project directory: -cd ~/dsagt-projects/quickstart && claude +# 2. Launch the agent from the project directory: +cd ~/dsagt-projects/quickstart && claude # …or: dsagt start quickstart ``` Inside the agent, paste these prompts one at a time (substitute the absolute path you exported as `$SMOKE_DIR` — the chat doesn't expand env vars): @@ -92,53 +91,37 @@ Inside the agent, paste these prompts one at a time (substitute the absolute pat 5. > Put this in explicit memory: samples.csv has null values in the status and timestamp columns. 6. > Tell me what you remember about the samples dataset. -Exit the agent (`Ctrl+C` or `/exit`), then distill the session into episodic memory and stop the MLflow daemon: - -```bash -# 4. After your session, distill traces into episodic memory: -dsagt memory --project quickstart - -# 5. Stop the MLflow daemon dsagt mlflow started (writes a PID into -# /.runtime; this releases the port and the gunicorn workers): -dsagt stop quickstart -``` - What this exercised: | Prompt | Layer | |---|---| | 1 | `dsagt-server` (`kb_ingest`) — chunks and indexes docs into ChromaDB | | 2 | `dsagt-server` (`save_tool_spec`) — writes `tools/csvcut.md`, `tools/csvgrep.md`, etc. (one per registered tool) | -| 3 | `dsagt-run` provenance wrapper — records exec layer to `trace_archive/` | -| 4 | Explicit memory (`kb_remember` → `explicit_memories.yaml`) + KB recall | +| 3 | `dsagt-run` provenance wrapper — records the execution layer to `trace_archive/` | +| 5–6 | Explicit memory (`kb_remember` → `.dsagt/explicit_memories.yaml`) + KB recall (`kb_get_memories`) | -Verify the artifacts and view traces in the MLflow UI (URL printed by `dsagt mlflow`): +Exit the agent (`Ctrl+C` or `/exit`), then verify the artifacts and view traces: ```bash -dsagt info quickstart +dsagt info quickstart # config + a session/trace summary ls ~/dsagt-projects/quickstart/{tools,trace_archive} -cat ~/dsagt-projects/quickstart/explicit_memories.yaml +cat ~/dsagt-projects/quickstart/.dsagt/explicit_memories.yaml + +# Traces land in a serverless SQLite store — no server to run. Browse them with: +mlflow ui --backend-store-uri sqlite:///$HOME/dsagt-projects/quickstart/mlflow.db ``` The same flow runs non-interactively via `dsagt smoke-test --agent claude` (or `goose` / `codex` / `opencode`), which asserts each artifact is present. -### First-time knowledge base setup +### Knowledge base provisioning -`dsagt setup-kb` builds the shared ChromaDB collections under `~/.dsagt/kb_index/` that every project on this machine reuses. Three of the six collections shown in the [architecture diagram](#architecture) are populated here — the other three are per-project and fill in automatically during use (see [Knowledge Base](#knowledge-base) below): +`dsagt init` provisions the project's knowledge base. The shared, machine-wide collections live under `~/dsagt-projects/kb_index/` and are built once (the first project on a machine pays the cost), then copied into each project's `kb_index/`: -- **Tool Specs** — DSAgt's bundled tool specs from `src/dsagt/tools/`, tagged with `source: bundled` so the agent finds them via `search_registry` from the very first session. -- **Skills Catalog** — the default external skill source (`scientific`) cloned and frontmatter-indexed so `search_skills` returns installable skills from the first session. Add more (`dsagt skills add genesis`, etc.). The bundled `skill-creator` is auto-discovered natively by the agent, not indexed. -- **Domain Knowledge** — Reference corpora (NVIDIA NeMo Curator, AI Data Readiness Inspector) downloaded and embedded so the agent has data-curation domain knowledge out of the box. +- **Tool Specs** — DSAgt's bundled tool specs from `src/dsagt/tools/`, tagged `source: bundled`, always provisioned so the agent finds them via `search_registry` from the first session. +- **Skill Catalogs** — the skill-catalog sources you check at init (default `genesis`) are cloned and frontmatter-indexed so `search_skills` returns installable skills. The bundled `skill-creator` is auto-discovered natively by the agent, not indexed. +- **Knowledge Collections** — optional reference corpora you check at init (`nemo_curator`, `aidrin`), downloaded and embedded for data-curation domain knowledge. -The Tool Specs collection is wipe-and-rebuild on every run, so re-run `setup-kb` after upgrading DSAgt to pick up new bundled tools. (Bundled skills are not indexed — agents auto-discover them natively.) - -```bash -dsagt setup-kb # all collections (local embedder, no creds) -dsagt setup-kb --collection nemo_curator -dsagt setup-kb --embedding-backend api --embedding-base-url ... --embedding-api-key ... -``` - -The default embedder is a local sentence-transformers model (~130 MB of weights downloaded on first run, CPU-side, no API key). Pass `--embedding-backend api` to route through a hosted embedder via LiteLLM (15–30 minutes typical for the reference corpora, depending on rate limits). +The default embedder is a local sentence-transformers model (~130 MB of weights downloaded on first run, CPU-side, no API key). ## Use Case Examples @@ -159,18 +142,20 @@ dsagt init my-project --agent claude --location /data/runs # /data/runs/my-pro dsagt init my-project --agent claude --location . # ./my-project/ ``` -Projects are registered in `~/.dsagt/projects.yaml` so `dsagt mlflow ` and `dsagt info ` work from any directory. The data layer (knowledge base, MLflow store, registered tools, skills, audit records) is agent-agnostic, so re-running `dsagt init --agent ` switches platforms while preserving everything you've accumulated. +Projects are registered in `~/dsagt-projects/projects.yaml` so `dsagt info ` works from any directory. The data layer (knowledge base, trace store, registered tools, skills, audit records) is agent-agnostic, so re-running `dsagt init ` and choosing a different agent switches platforms while preserving everything you've accumulated (it prompts before any destructive change). ``` ~/dsagt-projects/cheese-metagenome/ - dsagt_config.yaml # project configuration + .dsagt/ # dsagt-internal state (hidden) + config.yaml # project configuration (set by dsagt init) + state.yaml # session log + memory cursor (owned by the MCP server) + explicit_memories.yaml # user-confirmed facts tools/ # registered CLI tool specs (markdown + YAML frontmatter) tools/code/ # agent-written tool scripts skills/ # agent skills (SKILL.md + reference docs) trace_archive/ # tool execution records (JSON, from dsagt-run) - mlflow/ # MLflow traces, metrics, artifacts + mlflow.db # serverless MLflow SQLite trace store kb_index/ # knowledge base vector collections - explicit_memories.yaml # user-confirmed facts loaded at session start # Per-agent runtime config (one of, generated by dsagt init): # claude: CLAUDE.md, .mcp.json @@ -186,9 +171,7 @@ Projects are registered in `~/.dsagt/projects.yaml` so `dsagt mlflow ` and DSAGT exposes a single MCP server, **`dsagt-server`**, that an agent connects to once. It bundles two concern areas: - **Registry** — Tool registration and dependency installation. Tools are markdown files with YAML frontmatter under `/tools/`. Executables are wrapped with `dsagt-run` for provenance and `uv run --with` for Python dependencies. The agent discovers tools via `search_registry`. -- **Knowledge** — Semantic search over indexed document collections (FAISS / ChromaDB). Background jobs handle long ingest operations. The agent searches via `kb_search`, ingests via `kb_ingest`, and saves user-confirmed facts via `kb_remember`. - -> **Upgrading from the two-server layout?** Earlier versions ran separate `dsagt-registry-server` and `dsagt-knowledge-server` processes. There is no automatic migration: re-run `dsagt start ` and the per-agent MCP config is regenerated to point at the single `dsagt-server`. For **cline** specifically, `cline mcp add` has no remove, so an upgraded project keeps stale `dsagt-registry`/`dsagt-knowledge` entries alongside the new `dsagt` one — delete `/.cline-data` before `dsagt start` to get a clean config. +- **Knowledge** — Semantic search over indexed ChromaDB document collections. Background jobs handle long ingest operations. The agent searches via `kb_search`, ingests via `kb_ingest`, and saves user-confirmed facts via `kb_remember`. ### Tools and Skills @@ -199,53 +182,64 @@ DSAGT exposes a single MCP server, **`dsagt-server`**, that an agent connects to - **Installed** skills live in `/skills/` (DSAgt ships a bundled `skill-creator`; domain skills like the MODCON datacard generator are installed from the `genesis` catalog). These are mirrored into the agent's native skill directory (e.g. `.claude/skills/`, `.agents/skills/`) at `dsagt init`/`start`, where the agent auto-discovers and auto-invokes them — no `search_skills` needed (that covers only the catalog tier below). - **Catalog** skills come from external Git repositories — GitHub *or* GitLab — indexed into a searchable catalog the agent browses with `search_skills` but that is **not** loaded into its context (so a catalog can hold thousands of skills). The agent enables a source with `add_skill_source(...)`, finds skills with `search_skills(...)`, then copies one into the project with `install_skill(...)`. -The catalog is **opt-in**: a source must be synced before its skills are searchable. Curated named sources ship out of the box — `scientific`, `anthropic`, `antigravity`, `composio`, and `genesis` (the OSTI GENESIS catalog: HPC, HuggingFace, LangChain, OpenAI, plasma-sim, and more) — and any Git URL or `owner/repo` works too. Manage catalogs from the CLI with `dsagt skills list/search/add/sync `, or from the agent with `list_skill_sources` / `add_skill_source` / `search_skills` / `install_skill`. +The catalog is **opt-in**: a source must be synced before its skills are searchable. Curated named sources ship out of the box — `k-dense-ai`, `anthropic`, `antigravity`, `composio`, and `genesis` (the OSTI GENESIS catalog: HPC, HuggingFace, LangChain, OpenAI, plasma-sim, and more) — and any Git URL or `owner/repo` works too. Manage catalogs from the agent with `list_skill_sources` / `add_skill_source` / `search_skills` / `install_skill`. ![DSAgt skills routing](latex/skills-routing.png) -Skill handling runs through one service over two stores. **`SkillRouter`** is the single skill-MCP entry point — every skill tool routes through it: `add_skill_source` / `list_skill_sources` manage repos, `search_skills` queries the catalog, `install_skill` adopts a catalog skill into the project. **Registration** pulls skills from External Skills Repos (the curated `scientific` / `anthropic` / `antigravity` / `composio` / `genesis` sources, *or any git URL*) into the **Skills Catalog** — a federated, searchable store of *not-yet-installed* skills (semantic search, with a zero-dependency keyword fallback when no embedder is configured). **Discovery** is the catalog's irreplaceable job: surfacing skills the agent doesn't yet have, which native discovery can't see. **Progressive exposure** is native: the **Skill Directory** holds the project's installed + created skills in each agent's own skill dir (`.claude/skills`, `.agents/skills`, `.cline/skills`, `.roo/skills`), where the agent auto-discovers and model-invokes them by relevance — and authors new ones via the bundled **`skill-creator`** skill. The diagram source is [`latex/skills-routing.tex`](latex/skills-routing.tex). +Skill handling runs through one service over two stores. **`SkillRouter`** is the single skill-MCP entry point — every skill tool routes through it: `add_skill_source` / `list_skill_sources` manage repos, `search_skills` queries the catalog, `install_skill` adopts a catalog skill into the project. **Registration** pulls skills from External Skills Repos (the curated `k-dense-ai` / `anthropic` / `antigravity` / `composio` / `genesis` sources, *or any git URL*) into the **Skills Catalog** — a federated, searchable store of *not-yet-installed* skills (semantic search, with a zero-dependency keyword fallback when no embedder is configured). **Discovery** is the catalog's irreplaceable job: surfacing skills the agent doesn't yet have, which native discovery can't see. **Progressive exposure** is native: the **Skill Directory** holds the project's installed + created skills in each agent's own skill dir (`.claude/skills`, `.agents/skills`, `.cline/skills`, `.roo/skills`), where the agent auto-discovers and model-invokes them by relevance — and authors new ones via the bundled **`skill-creator`** skill. The diagram source is [`latex/skills-routing.tex`](latex/skills-routing.tex). ### Knowledge Base -Six independently-partitioned ChromaDB collections hold everything the agent searches semantically. The first three are global (under `~/.dsagt/kb_index/`, populated by `dsagt setup-kb`); the last three are per-project (under `/kb_index/`, populated automatically during use): +Six independently-partitioned ChromaDB collections hold everything the agent searches semantically. The first three are machine-wide (built once under `~/dsagt-projects/kb_index/` and copied into each project); the last three are per-project (under `/kb_index/`, filled automatically during use): | Collection | Source | Populated by | |---|---|---| -| **Tool Specs** | Bundled CLI tool specs in `src/dsagt/tools/` | `dsagt setup-kb` | -| **Skills Catalog** | Installable skills from external repos (one `skills_catalog__` per source), frontmatter-indexed | `dsagt setup-kb` (default source) + `add_skill_source` | -| **Domain Knowledge** | NeMo Curator + AIDRIN reference corpora; user-ingested docs | `dsagt setup-kb` + agent's `kb_ingest` | -| **Explicit Memory** | User-confirmed facts | Agent's `kb_remember` (also written to `/explicit_memories.yaml`); the agent fetches via `kb_get_memories` on demand — typically when you ask it to recall — not auto-loaded at session start | -| **Episodic Memory** | Distilled facts from MLflow traces | `dsagt memory --project ` (per-category outlier detection via embedding centroids) | -| **Tool Use Records** | `dsagt-run` execution traces | `dsagt-run` wrapper writes JSON to `/trace_archive/`; indexed into ChromaDB by `dsagt memory` | +| **Tool Specs** | Bundled CLI tool specs in `src/dsagt/tools/` | `dsagt init` (always provisioned) | +| **Skill Catalogs** | Installable skills from external repos (one `skills_catalog__` per source), frontmatter-indexed | `dsagt init` (chosen sources) + `add_skill_source` | +| **Knowledge Collections** | NeMo Curator + AIDRIN reference corpora; user-ingested docs | `dsagt init` (chosen collections) + agent's `kb_ingest` | +| **Explicit Memory** | User-confirmed facts | Agent's `kb_remember` (also written to `/.dsagt/explicit_memories.yaml`); the agent fetches via `kb_get_memories` on demand, not auto-loaded at session start | +| **Tool Use Records** | `dsagt-run` execution traces | `dsagt-run` writes JSON to `/trace_archive/`; embedded into ChromaDB incrementally by the MCP server's heartbeat (idempotent), and on demand before `reconstruct_pipeline` | +| **Episodic Memory** | Distilled session facts | **Opt-in** (`dsagt init --episodic`): the heartbeat distills each completed turn into tagged, ≤1-sentence facts via a local LLM judge (Tier-1), falling back to mechanical chunk+tag+embed (Tier-0) on judge failure. Retrieval is recency-weighted. | -The default embedding backend is local (sentence-transformers, CPU-side, no API needed). Switch to `embedding.backend: api` in `dsagt_config.yaml` to route through a hosted embedder via LiteLLM. Cross-encoder reranking is optional (`knowledge.rerank: true`). +The embedding backend is local (sentence-transformers, CPU-side, no API key). The agent searches via `kb_search` and writes via `kb_ingest` / `kb_remember`. Registered tools have their own `search_registry` route over the same backend. Installed skills are auto-discovered natively by the agent (not indexed); enabling external skill catalogs adds one `skills_catalog__` collection per source, which `search_skills` browses for installable skills. +### Memory + +DSAgt has two memory types, both retrievable via `kb_search` / `kb_get_memories`: + +- **Explicit memory** — user-confirmed facts the agent writes with `kb_remember` (mirrored to `/.dsagt/explicit_memories.yaml`). Always on; degrades to pure-YAML if the vector store is unavailable. +- **Episodic memory** — automatic session facts, **opt-in** (`dsagt init --episodic`, off by default). The MCP server's in-session heartbeat reads the agent's transcript and, each completed turn, distills it into a few tagged, ≤1-sentence facts in the `session_memory` collection. Two tiers: + - **Tier-1 (default when enabled)** — a small **local** LLM "judge" (`Qwen2.5-1.5B`, grammar-constrained) classifies each fact against a closed tag taxonomy (stock "AI-data-ready" tags + any project `--domain-tags`) and condenses it. Local-by-default — **no API key, no cost** — but the GGUF model (~1 GB) downloads on first use and inference uses CPU (~1 s per fact-bearing turn, off the agent's critical path). + - **Tier-0 (fallback)** — mechanical chunk + keyword-tag + embed, no LLM; used automatically if the judge fails, so a turn is never lost. + + Retrieval is **recency-weighted** (`episodic.recency_half_life_days`, default 14): a newer fact edges out a stale one, but as a bounded boost — a strongly-relevant old fact is never buried. Enabling the local judge needs no setup beyond the bundled `llama-cpp-python` (a core dependency, installed from a prebuilt CPU wheel). + ### Observability -MLflow runs at `http://localhost:` (pinned at init time, listed by `dsagt info`). The trace view shows: +Self-logging goes to a serverless MLflow SQLite store at `/mlflow.db` — no server to run. Browse it with `mlflow ui --backend-store-uri sqlite:////mlflow.db`. The trace view shows: - **Knowledge base operations** — `kb.search` / `kb.embed` / `kb.index_search` / `kb.rerank` span trees with per-phase timing. - **Tool executions** — `tool.execute` spans with exit code, duration, file counts, truncated stderr. Full payload in `trace_archive/.json`. - **Registry events** — `save_tool_spec`, `install_dependencies`, `reconstruct_pipeline` spans. -- **Native agent OTel** *(optional)* — when you export `MLFLOW_TRACKING_URI` and `OTEL_EXPORTER_OTLP_ENDPOINT` (printed by `dsagt init`), the agent's own LLM-call traces land in the same MLflow store. Trace coverage varies by agent: claude / goose emit full payloads, codex emits token counts + tool names, opencode emits nothing natively. +- **Agent traces** — recovered post-hoc from the on-disk session transcript by the MCP server's in-session heartbeat (a per-agent reader → canonical-trace translator → MLflow sink), so prompts/responses/tool-calls land in the store for every supported agent, not just claude. (claude additionally wires an `mlflow autolog` Stop hook at `dsagt init`.) -Every span carries the project's `session.id` for filtering. Tool execution records on disk provide the canonical provenance chain — the agent calls `reconstruct_pipeline` to render the trace archive as a reproducible bash script or Snakemake workflow. +The MCP server mints a session id per launch into `/.dsagt/state.yaml`, and every span carries it for filtering. Tool execution records on disk provide the canonical provenance chain — the agent calls `reconstruct_pipeline` to render the trace archive as a reproducible bash script or Snakemake workflow. ## CLI Reference | Command | Description | |---------|-------------| -| `dsagt init --agent [--location ] [--mlflow-port N]` | Create a project; write per-agent MCP config; print launch one-liner | -| `dsagt mlflow ` | Run MLflow in the foreground against a project's store (port pinned at init time) | -| `dsagt memory --project ` | Distill new traces from this project's MLflow into episodic memory | -| `dsagt info [--json]` | Resolved config (with source per value) and a session/error summary | -| `dsagt setup-kb [--collection ]` | Build the shared core knowledge base collections | -| `dsagt skills [source]` | Manage external skill catalogs and project skill installs | -| `dsagt list` | List all projects with agent, status, and path | +| `dsagt init []` | Create or reconfigure a project — interactive: agent, location, knowledge collections, skill sources, and the episodic-memory opt-in; provisions the KB and writes the per-agent MCP config | +| `dsagt init --agent [--location ] [--include … \| --exclude …] [--episodic [--domain-tags "a,b"]]` | Same, non-interactively (for scripts/CI); `--episodic` enables episodic memory (downloads the ~1 GB local judge on first use) | +| `dsagt start ` | Launch the agent in the project directory (equivalent to `cd && `) | +| `dsagt info [--json]` | Resolved config (with source per value) and a session/trace summary | +| `dsagt list` | List all projects with agent and path | | `dsagt mv ` | Move a project to a new location | | `dsagt rm [-y] [--keep-files]` | Unregister a project (and optionally delete its directory) | | `dsagt smoke-test [--agent claude\|goose\|codex\|opencode]` | End-to-end install verification | -For tests, proxy mode, troubleshooting, and other developer-facing material, see [developer.md](developer.md). +Skill catalogs are managed from the agent via the MCP tools (`add_skill_source` / `search_skills` / `install_skill`), and traces are viewed with `mlflow ui --backend-store-uri sqlite:////mlflow.db`. + +For tests, troubleshooting, and other developer-facing material, see [developer.md](developer.md). diff --git a/design-notes/genesis-skills-comparison.md b/design-notes/genesis-skills-comparison.md deleted file mode 100644 index aed6644..0000000 --- a/design-notes/genesis-skills-comparison.md +++ /dev/null @@ -1,516 +0,0 @@ -# Design Note — Genesis Skills vs DSAGT skill discovery - -**Status:** implemented (2026-06-23). Sections 0–6 are the original close read of -the Genesis Skills discovery engine vs DSAGT's. Sections 7–9 record the agreed -design as it stood mid-plan. **§10 records what actually shipped and supersedes -the tier-3 / progressive-disclosure / recency-queue parts of §7.4–7.6 and §9** — -a research pivot (every supported agent natively discovers SKILL.md) collapsed -those. Read §10 for the final architecture; §7–9 are kept for design history. - -**Date:** 2026-06-23 -**Author:** comparison drafted with Claude Code - -- Genesis Skills: - (the `skill-search/` engine + a curated 74-skill catalog under `skills/`) -- DSAGT skill discovery: [skills_catalog.py](../src/dsagt/commands/skills_catalog.py), - [registry.py](../src/dsagt/registry.py) (`SkillRegistry`), - [registry_server.py](../src/dsagt/commands/registry_server.py) (`search_skills`/`install_skill`), - [knowledge_server.py](../src/dsagt/commands/knowledge_server.py) (`add_skill_source`/`list_skill_sources`) - ---- - -## 0. Relationship (important framing) - -This is **not a competitor** — it's a sibling DOE effort. Genesis Skills is part -of the "Genesis Mission" (`genesis@osti.gov`, gitlab.osti.gov/genesis), and its -catalog includes a **`modcon-skills/`** category (`datacard-generator`, -`croissant-validator`, `hdmf-schema-builder`) — i.e. our own BASE-Data/ModCon -skills. DSAGT (`AI-ModCon`, BASE-Data team) and Genesis are in the same orbit. - -DSAGT already **consumes** Genesis as a catalog source (the `genesis` entry in -`KNOWN_SOURCES`, subdir `skills`, ~72 of 74 skills index cleanly; 2 are skipped -for malformed upstream YAML). So the natural relationship is **complementary**: -DSAGT = semantic, multi-source, platform-integrated layer that *indexes* Genesis -(and others); Genesis = portable, standard-conformant content + lightweight -discovery. - ---- - -## 1. What Genesis built - -A focused, polished, single-purpose **skill-discovery engine** (`skill-search/`) -plus a curated catalog (74 skills across 10+ domains). - -- **Search method:** pure-Python **keyword token-overlap** scoring - (`skill-search/skill_search/catalog.py`). Name-token matches ×2, - description-token matches ×1, plus exact/substring bonuses (+6 exact name, - +4 substring name, +2 substring description), stopword filtering, deterministic - tie-break by name. **Zero dependencies, no embeddings, no DB, no model - download.** -- **Two discovery strategies:** - 1. `--query` → top-k keyword matches (keeps full catalog out of context). - 2. `--load-all` → progressive disclosure: compact index of *all* skills - (name/description/path) upfront, load `SKILL.md` body on demand. - 3. `--include-prompt` → emits an `` XML block for direct - system-prompt injection. -- **Discovery:** filesystem recursion over nested `skills///`, - merged by name with **override semantics** (central catalog + user roots; later - roots win). No persistent index — re-scanned each call. -- **The engine is itself a Skill** (`SKILL.md`, `allowed-tools: Bash`): the agent - runs it via Bash and gets JSON back. **No server.** -- **Distribution:** `unpack.sh` flattens/symlinks skills into an agent's native - dir across **multiple harnesses** (Claude Code, Gemini, Codex `.agents/skills/`); - LangChain/LangGraph import the `skill_search` package directly. -- **Standard conformance:** explicitly follows the **agentskills.io open - standard** (`skill_spec.md` mirrors the spec), points at `skills-ref validate`, - and has real license hygiene (Apache-2.0, `NOTICE` inventory, per-subtree - licenses for vendored third-party skills). -- **Engineering polish:** standalone package (`catalog`/`frontmatter`/`models`/ - `prompt`/`tooling`/`errors`), ~500 lines of unit tests, layered deployment - resolution (`--central-root` flag → `SKILL_SEARCH_CENTRAL_ROOT` env → sibling - `../skills`). - ---- - -## 2. What DSAGT has - -Skill discovery is **one feature inside a platform** (KB, tool registry, -provenance, observability, memory), exposed over MCP. - -- **Search method:** **semantic embeddings + BM25 hybrid** over ChromaDB - (local `bge-small-en-v1.5` or hosted via LiteLLM; `route.json` `hybrid: true`, - `bm25.pkl` present). Better recall on natural-language queries. -- **Index:** persistent **per-source ChromaDB collections** - (`skills_catalog__`), built by `sync_source` (clone → index). Sparse - clone via `subdir`. Machine-global cache at `~/.dsagt/.skill_sources/`. -- **Sources:** many **remote** sources (GitHub *and* GitLab), curated - `KNOWN_SOURCES` (`scientific`, `anthropic`, `antigravity`, `composio`, - `genesis`) **plus any git URL / `owner/repo`**. -- **Runtime:** MCP server tools the agent calls directly — `search_skills`, - `add_skill_source`, `list_skill_sources`, `install_skill` — plus the - `dsagt skills sync/add/list/search` CLI. -- **Two tiers:** *installed* skills (mirrored into native `.claude/skills/` at - init/start, auto-invocable) vs *catalog* skills (indexed, not in context). -- **Dependencies:** requires an embedder (~130 MB local model or an API key). - ---- - -## 3. Side-by-side - -| Dimension | Genesis `skill-search` | DSAGT | -|---|---|---| -| Search | Keyword token-overlap, deterministic | Semantic embeddings + BM25 hybrid | -| Index | None; re-scans filesystem each call | Persistent per-source ChromaDB | -| Sources | One filesystem tree (its own repo) | Many remote sources (GitHub + GitLab), curated + arbitrary URL | -| Runtime | A Bash-invoked Skill; no server | MCP server tools + CLI | -| Dependencies | Zero (stdlib only) | Embedder (~130 MB local or API) | -| Scope | Just skill discovery/distribution | One feature in a platform (KB/registry/provenance/memory) | -| Standard | agentskills.io conformant + validation | Own SKILL.md convention (close, not formalized) | -| Multi-harness install | `unpack.sh` (Claude/Gemini/Codex) | Mirrors to `.claude/skills/` (per-agent) | -| Context modes | Search top-k, full index, prompt-XML | Search returns summaries; catalog kept out of context | -| Tests/packaging | Dedicated package + ~500 LOC unit tests | Solid, embedded in the larger codebase | -| License hygiene | NOTICE + per-subtree licenses | Not tracked on `install_skill` copy | - ---- - -## 4. Honest assessment - -**Where Genesis is better developed (the narrow slice):** -- Open-standard conformance + validation tooling. -- Portability — no server, works across harnesses, the engine ships *as a skill*. -- Zero-dependency, deterministic, instant — no embedder download, no API, reproducible. -- Cleaner standalone package with tests; proper license/attribution for vendored skills. -- A large curated catalog ready to go. - -**Where DSAGT is stronger (what matters for the platform):** -- Semantic + hybrid search beats keyword overlap on fuzzy/natural queries. - (Keyword overlap misses synonymy: "submit a batch job" won't score `slurm` - unless tokens literally overlap.) -- Multi-source, persistently-indexed catalog federating many remote hosts — - Genesis searches one tree; DSAGT federates Genesis *plus* K-Dense, Anthropic, … -- Integration: skills live alongside the KB, tool registry, provenance, and - memory in a reproducible pipeline workflow. - ---- - -## 5. Borrowables (candidate work items, prioritized) - -1. **Keyword fallback for `search_skills` when no embedder is configured.** - Today `search_skills` dead-ends with "requires a configured knowledge base" - when `kb is None`. Genesis's token-overlap scorer - (`catalog.py:_score_skill` / `rank_skills`) is exactly the zero-dependency - fallback that would make search work without the 130 MB model. **Highest - value / lowest cost**; directly fixes the no-embedder gap. -2. **Adopt the agentskills.io standard explicitly** for DSAGT's SKILL.md - (already ~compatible) and add a validation step (`skills-ref validate` or - equivalent). Improves interop — and we now feed from a standard-conformant - source. -3. **Progressive-disclosure prompt block** (`` XML) — a cheap - mode for small catalogs that skips the index entirely. -4. **License / NOTICE hygiene on `install_skill`.** When a (possibly - third-party) catalog skill is copied into a project, preserve its `LICENSE`. - Genesis tracks this per-subtree; DSAGT currently doesn't. -5. **Lean into complementarity.** Keep DSAGT as the semantic, multi-source, - platform-integrated layer that indexes Genesis (and others). Consider - coordinating with the Genesis team — modcon-skills already overlaps, so - there's a shared-content story. - ---- - -## 6. Open questions for the merge decision (pick up later) - -- Do we want DSAGT to *re-export* an agentskills.io-conformant catalog (so other - tools, incl. Genesis `skill-search`, can consume DSAGT's skills)? -- Should the keyword fallback (#1) be a permanent low-tier search mode (fast, - cheap, deterministic) selectable even when an embedder *is* available? -- Is there appetite to upstream DSAGT/ModCon skills into Genesis rather than - maintain parallel copies (the `modcon-skills/` overlap)? -- Packaging: is the Genesis `skill_search` Python package worth depending on - directly for the fallback, or do we reimplement the (small) scorer to avoid a - dependency on an external repo? - ---- - -## 7. Decided plan (2026-06-23) - -The architecture below is **not a rewrite** — DSAGT's existing chain already *is* -the curation + per-project-install model we want. The plan is a set of insertions -on top of it. - -### 7.1 Earmarked borrowables (committed) - -1. **Keyword fallback for `search_skills` when `kb is None`.** Reimplement (do - **not** depend on) Genesis's `_score_skill` / `rank_skills` token-overlap - scorer — ~50 LOC, vendored with attribution. Insert it *before* the - `kb is None` dead-end at `registry_server.py:324`. It reads frontmatter off - the on-disk clone cache (`~/.dsagt/.skill_sources/`) + bundled skills, which - already exists because `sync_source` clones even when `kb is None` - (`skills_catalog.py:218`). No embedder, no separate index. - *Status (built):* `src/dsagt/skill_keyword.py` mirrors Genesis exactly — - weights (name ×2 / desc ×1), **mutually-exclusive** substring bonus tiers - (+6 exact / +4 name / +2 desc, via `elif`), the same stopword set, and the - casefold-`\w+`-hyphen-split tokenizer that drops single-char tokens. - Verified against the upstream source, not just the prose spec. It is strictly - the *no-KB* path; when a KB exists the router uses ChromaDB (whose hybrid mode - already includes BM25), so the scorer and BM25 are mutually exclusive and - never double-rank. -2. **License / NOTICE hygiene on `install_skill`.** `install_into_project`'s - `copytree` (`skills_catalog.py:313`) already carries a *skill-dir-local* - LICENSE. The gap is the source repo's **root NOTICE / per-subtree license - provenance** — capture it at sync time (Stage A) and stamp it at install. - -### 7.2 Shipped-skill cut - -DSAGT ships only two skills today — `datacard-generator` (frontmatter name -`generating-datacards`) and `skill-creator` — plus one tool (`scan_directory`). - -- **Strike `datacard-generator`** from the repo; it lives in Genesis - `modcon-skills/`. Users get it via `add_skill_source genesis` (catalog tier). - **Verify the Genesis copy exists and matches before deleting**, then leave a - pointer. This makes Genesis the canonical home (open question #3 becomes a real - coordination dependency). -- **Keep `skill-creator`** as the single minimal shipped skill: it's - *infrastructure*, not domain content, so it doesn't belong in Genesis's curated - domain catalog; it's self-referential (the harness can scaffold test skills with - it); and it's stable. Shipped skill + `scan_directory` tool exist primarily as - **test-harness fixtures**, since no shipped tool does more than wrap standard CLI. - -### 7.3 Current MCP call chain (the anchor) - -``` -add_skill_source / list_skill_sources → search_skills → install_skill → dsagt start - (expose catalogs) (select a subset) (draw into project) (activate natively) - knowledge_server registry_server registry_server → agents/claude.py - skills_catalog _mirror_skills_to -``` - -- **Stage A — Expose.** `add_skill_source` → `resolve_source` → `sync_source`: - sparse-clone by `subdir` into `~/.dsagt/.skill_sources//`, then - `index_catalog` **wipes + rebuilds** that one source's - `skills_catalog__` ChromaDB collection; persists the source to - `dsagt_config.yaml`. `list_skill_sources` reports `KNOWN_SOURCES` + synced - state. (`skills_catalog.py:185`, `knowledge_server.py:609`) -- **Stage B — Select.** `search_skills` searches `SKILLS_COLLECTION` (installed) - **plus every** `skills_catalog__` collection, merges by score, returns - top-k tagged `[installed]` / `[catalog · install_skill to add]`. Dead-ends when - `kb is None` (except exact `skill_name`). (`registry_server.py:305`) -- **Stage C — Draw.** `install_skill` → `find_catalog_skill` (cross-source - ambiguity guard) → `install_into_project` copies the skill dir into - `/skills//`, re-indexes it as `registered`. - (`registry_server.py:390`, `skills_catalog.py:296`) -- **Stage D — Activate.** Next `dsagt start` mirrors `/skills/` → - `.claude/skills/` for native auto-invocation — **only `claude.py` does this**; - goose/cline/roo/codex have no native mirror. (`agents/claude.py:182`, - `agents/base.py:251`) - -Installed skills promoted via `install_skill` become part of the **core installed -set** for future sessions in that project. - -### 7.4 Tiering & backend selection (the core design) - -> ⚠️ **Superseded by §10.** Tiers 2/3 and the budget threshold below assumed some -> agents lack native skill discovery. Research proved otherwise — *all* supported -> agents are native, so tier 3 (and the disclosure block + recency queue it -> motivated) was never built. The *catalog vs installed* split and the -> *ChromaDB-or-keyword* backend selection survive; the rest is history. - - -Progressive disclosure and ChromaDB are **not competitors** — they sit on -different axes: - -- **What is disclosed:** installed (core, project) vs. catalog (federated, remote). -- **How the index is produced:** *full dump* (deterministic, no query) vs. - *query-driven selection* (needs a query, returns top-k). - -Both produce the **same `` block** (the agentskills.io-style -output contract). Backend is chosen by **context budget**, not by tier: - -> Full-dump while the disclosed set fits the budget; switch to query-driven -> *selection* when it doesn't. "Selection" = **ChromaDB if an embedder exists, -> Genesis keyword scorer if not.** ("Fall back on ChromaDB" is shorthand — the -> real fallback is to *selection*, whose backend is itself tiered.) - -Three operating tiers: - -1. **Catalog (any harness)** — *always* query-driven. Unbounded (Genesis + - Anthropic + …), so never full-dumped. ChromaDB top-k, or keyword scorer when - no embedder. -2. **Installed, native harness (Claude)** — dsagt **defers** to the harness. - `_mirror_skills_to` populates `.claude/skills/`; Claude's own runtime injects - names/descriptions and lazy-loads bodies (note the `_NATIVE_DESCRIPTION_CAP` - truncation at `base.py:231` — *Claude's* limit, not dsagt's). dsagt emits no - block here, so its budget threshold never fires. -3. **Installed, non-native harness (goose/cline/roo/codex)** — dsagt **is** the - producer of the block, because the harness has no native discovery. Full-dump - the installed set into the agent's instructions file until the context budget - is hit; past that, drop to a short pointer + query-driven selection. See 7.5. - -### 7.5 The non-native third tier, by example - -On goose/cline/roo/codex an installed skill lands in `/skills//` -but **nothing auto-injects it** — today the agent only finds it by calling -`search_skills`. The third tier closes that gap: dsagt emits the -progressive-disclosure block the harness won't. - -*Goose, 5 installed skills* (`skill-creator`, `generating-datacards`, -`slurm-submit`, `croissant-validator`, `fastq-qc`): at `dsagt start`, dsagt writes -an `` block (name + description + path per skill) into -`.goosehints`. ~5 × 40 ≈ **200 tokens** → full-dump. Goose passively knows all -five and reads a SKILL.md body on demand. No embedder, no query. - -*Same project, 100 installed skills*: the block is now ~4,000 tokens **every -session**. Past the budget, dsagt stops full-dumping, leaves a short pointer -("call `search_skills`…"), and lets selection carry it (ChromaDB top-k, or keyword -scorer). The agent pulls ~3 relevant skills per task instead of carrying all 100. - -This threshold fires **only** in tier 3 — the one case where dsagt both produces -the block and pays its context cost. Codex graduates tier 3 → tier 2 in this pass -by mirroring into its natively-discovered, **project-local** `.agents/skills/` -(§9.7), so it stops going through dsagt's threshold; goose/cline/roo remain -tier 3. - -### 7.6 The discovery router (the consolidating abstraction) - -All of the policy in 7.4–7.5 — *which backend, which tier, full-dump vs select, -installed vs catalog, defer-to-harness vs emit* — lives in **one** place rather -than smeared across the MCP handler, `base.py`, and each agent file. Otherwise the -decision tree gets re-implemented and drifts at every call site. - -**Home:** new module `src/dsagt/skill_discovery.py` (a *module*, not a command), -class `SkillRouter`. It **owns policy and delegates execution.** - -```python -class SkillRouter: - def __init__(self, *, kb, skill_registry, project_dir, agent, config): ... - - def search(self, query=None, *, top_k=8, tag=None, skill_name=None) -> str: - # Stage B. Owns backend choice (ChromaDB vs keyword vs full-dump), - # installed+catalog merge, no-embedder fallback, rendering. - - def disclosure_block(self) -> str | None: - # Stage D. Owns tier resolution + budget threshold. Returns None when - # the harness owns the tier (native). - - def list_sources(self) -> list[dict]: - # Stage A view. KNOWN_SOURCES + per-collection indexed count + synced? - # one consistent view for both the MCP handler and the CLI. -``` - -- **Owns:** backend selection (`_select`: kb→ChromaDB, else keyword scorer), - tier resolution, budget threshold (`_mode_for_installed`), and the single - `` renderer (`_render`, used by *both* `search` and - `disclosure_block`). -- **Delegates (unchanged):** `kb.search`, `sync_source` / `install_into_project` - (`skills_catalog`), `_discover_skill_dirs` / `_parse_frontmatter`, - `_mirror_skills_to`. It is a coordinator, not a reimplementation. -- **Three thin call sites:** `registry_server._handle_search_skills` → - `router.search`; each agent's `write_dynamic` → `router.disclosure_block`; and - the **CLI** `dsagt skills search/list` (`cli.py:_cmd_skills`) → `router.search` - / `router.list_sources`. The CLI is the decisive case: `cli.py:485-509` is - *already* a drifted copy of the `_handle_search_skills` merge logic (no `tag`, - no exact-`skill_name`, no `kb is None` path, different render). The router - collapses both copies into one. -- **`list_skill_sources` exposure status** (`knowledge_server.py:609`) also folds - into `router.list_sources()` — today the MCP handler and CLI `skills list - --catalog` compute "what's synced" differently. -- **Nativeness becomes explicit:** each agent declares `native_skills: bool` - (claude=True, codex=True; goose/cline/roo=False) instead of it being implicit in - "only claude calls `_mirror_skills_to`." The router reads the flag for tier 2 vs - 3. Codex is tier 2 via a **project-local** `.agents/skills/` mirror only (see §9.7). - -**Materialize vs. disclose (don't conflate).** Two separate jobs, both keyed off -`native_skills`: - -- *Materialize* = put skill files where the agent expects them. **dsagt does this - for every tier.** Native (claude): `_mirror_skills_to` → `.claude/skills/`. - Non-native: skills just live in `/skills/` from install (no native dir). -- *Disclose* = make the agent aware of them in context. Native: the harness does - it (router returns `None`). Non-native: `router.disclosure_block()` emits the - block. - -So the router defers tier-2 **disclosure** to Claude — **not** materialization. -dsagt still mirrors into `.claude/skills/`. The agent's `write_dynamic` is -`if native_skills: _mirror_skills_to(...) else: write(router.disclosure_block())`. -(`_mirror_skills_to` stays outside the router as materialization *execution*, not -because dsagt is hands-off for native harnesses.) - -**Recency queue (dedup + un-bury).** SkillRouter owns a length-bounded, -session-scoped FIFO of recently-exposed skill names. Skills emitted in the -disclosure block or returned by `search` are pushed on; while a skill is *fresh* -(in the queue) `search` suppresses re-surfacing it — it's already in context. As -new exposures push it off the tail it ages out and becomes eligible again (by -then likely buried in the transcript). This unifies the old double-listing and -re-emit threads: `install_skill` marks the new skill fresh, so it isn't -redundantly re-surfaced and no disclosure re-emit is needed. The queue **must be -disk-backed** (`/.dsagt/exposed_skills.json`) because the disclosure -block (agent-setup process) and `search` (MCP server process) are separate -processes that share it. *Refinement:* an explicit query that hits a fresh skill -returns a terse "already available" pointer rather than an empty result. - ---- - -## 8. Implementation surface (router-centric) - -Most changes land *inside* `SkillRouter`; call sites stay thin. - -| Change | Where | Kind | -|---|---|---| -| `SkillRouter` (backend select, tier, budget, render) | new `src/dsagt/skill_discovery.py` | New module | -| Vendored keyword scorer (genesis-derived, attributed) | new `src/dsagt/skill_keyword.py`, called by `router._select` | New code | -| `search_skills` → router | `registry_server._handle_search_skills` (replace body with `router.search`) | Thin call site | -| `disclosure_block` → router | `agents/{goose,cline,roo,codex}.py` `write_dynamic` (one-line call) | Thin call site | -| **CLI** `skills search/list` → router | `cli.py:_cmd_skills` (`cli.py:485-509` — kill the drifted dup) → `router.search` / `router.list_sources` | Thin call site | -| `list_skill_sources` → router | `knowledge_server.py:609` → `router.list_sources` | Thin call site | -| `native_skills` flag | each `agents/*.py` (declare True/False) | Declaration | -| License/NOTICE capture | `sync_source` + `install_into_project` (`skills_catalog.py:313`) — data-plane, outside router | Augment | -| Strike `datacard-generator` | `src/dsagt/skills/` + `pyproject.toml` package-data | Deletion | - -**Stays outside the router (delegated data / execution):** `SkillRegistry` -(installed-skills data + indexing — router reads it); `sync_source` / -`index_catalog` / `find_catalog_skill` / `install_into_project` (catalog -execution); `_mirror_skills_to` / `_truncate_native_description` (tier-2 native -mirror); `resolve_source` / `KNOWN_SOURCES` / `persist_source_to_config` (source -config). `_parse_frontmatter` and `_discover_skill_dirs` are already -single-sourced shared primitives — the router imports them, no move needed. - ---- - -## 9. Resolved decisions (2026-06-23) - -1. **Budget threshold** — a **token count** (estimated, e.g. `chars/4` to avoid a - tokenizer dependency), with a sensible default, as a property of `SkillRouter` - (per-agent overridable). The router measures it. -2. **Double-exposure** — solved by the recency queue (§7.6), not a static rule. - While a skill is *fresh* (in the session queue) `search` won't re-surface it. -3. **Mid-session freshness** — **no re-emit.** The agent retains newly installed - skills in context (`install_skill` returns the SKILL.md), and the queue marks - them fresh so `search` won't redundantly re-surface them. -4. **agentskills.io conformance** — adopt only the `` **output** - shape. **Not** full input conformance: strict validation would force - rewrite-or-exclude on third-party repos and add validation/normalization code — - *more* complex, not simpler, so it fails the "only if it simplifies" test. Keep - lenient parsing (parse what we can, skip malformed — as today). -5. **Keyword-scorer latency over large caches** — deferred; revisit only if - non-KB users hit usability issues. -6. **`datacard-generator`** — strike dsagt's copy; it's stale, the Genesis copy is - more current. *Pre-deletion check:* confirm the Genesis copy indexes cleanly - (isn't one of the 2 malformed-YAML skips) before removing ours. -7. **Codex → tier 2 now, project-local only.** Reuse the manifest-tracked - `_mirror_skills_to` pointed at `/.agents/skills/`. **Never** write - global `~/.agents/skills/` or `~/.codex` config, and (via `.dsagt-managed.json`) - never touch user-authored skills — footprint stays confined to the project dir - and transparent. *Verify:* Codex auto-discovers a project-local `.agents/skills/`. - ---- - -## 10. Implementation outcome (shipped 2026-06-23) - -Supersedes the tier-3 / progressive-disclosure / recency-queue parts of §7.4–7.6 -and §9. - -### 10.1 The research pivot - -Before building the agent piece, we verified (primary-source web research, four -independent passes + adversarial check) whether goose/cline/roo actually lack -native skill discovery. **They don't — every supported agent natively -auto-discovers SKILL.md skills:** - -| Agent | Native dir (project) | Notes | -|---|---|---| -| claude | `.claude/skills` | on by default | -| codex | `.agents/skills` | project-local (repo-root), on by default | -| goose | `.agents/skills` | built-in extension, on by default (also reads `.goose/`, `.claude/`) | -| cline | `.cline/skills` | **opt-in** — Settings → Features → Enable Skills (v3.48, Jan 2026) | -| roo | `.roo/skills` | v3.38 (May 2026); the "Roo shut down" rumor was false | - -**Consequence: tier 3 does not exist.** With no non-native harness there is -nothing to emit a disclosure block *for*. So we did **not** build: the -`` block, the sidecar, the `native_skills` boolean, the budget -threshold, or `disclosure_block()`. And the **recency queue was dropped** — its -sole rationale was the disclosure↔search double-exposure problem, which evaporates -without disclosure. `search` is now stateless. - -### 10.2 Final architecture (two concerns, cleanly split) - -**Materialization** (agent layer) — `AgentSetup.setup_skills(working_dir, config)` -mirrors installed (bundled + project) skills into each agent's `native_skills_dir` -via the manifest-tracked `_mirror_skills_to`. Called **once centrally** in -`agents/__init__.py:dynamic_agent_record` (covers all agents, BYOA + proxy). Each -agent declares `native_skills_dir` (class attr); gated by `skills.populate_native` -(default true). Codex/goose use the cross-agent `.agents/skills` standard. - -**Discovery** (`src/dsagt/skill_discovery.py:SkillRouter`) — stateless: -- `search(query, top_k, tag, skill_name)` — catalog tier + no-embedder keyword - fallback (`skill_keyword.py`, a faithful Genesis port). Installed skills are - natively advertised by every agent, so the catalog (not-yet-installed skills) - is the router's irreplaceable job; the keyword path also covers the - Cline-skills-disabled case. -- `list_sources()` — consolidated synced/indexed view. -- Three thin call sites: MCP `search_skills`, CLI `skills search/list`, - `knowledge_server.list_skill_sources`. - -### 10.3 What shipped - -- `src/dsagt/skill_keyword.py` — Genesis-faithful token-overlap scorer (verified - against upstream source: weights, `elif` bonus tiers, stopwords, tokenizer). -- `src/dsagt/skill_discovery.py` — stateless `SkillRouter`. -- `agents/base.py` — `native_skills_dir` ClassVar + `setup_skills`; called in - `dynamic_agent_record`. `native_skills_dir` set on all five agents; claude's - inline mirror removed (now central). -- Call sites rewired: `registry_server` (search), `cli.py` (search/list), - `knowledge_server` (list_sources). -- **License/NOTICE capture on install (done).** `install_into_project` → - `_capture_attribution`: copytree carries skill-local files; ancestor dirs up to - the cache repo root are walked for `LICENSE` / `NOTICE` / `COPYING` / - `ATTRIBUTION` (clone_github mirrors repo-root files into the cache even for - sparse `subdir` clones), nearest wins, and a `PROVENANCE.txt` is stamped. - `install_skill` surfaces what was preserved. -- **Struck the stale `datacard-generator` shipped skill (done).** Verified first - via the GitLab API + raw SKILL.md that Genesis's copy - (`skills/modcon-skills/datacard-generator`, name `generating-datacards`) has - well-formed frontmatter (not a malformed skip). Removed the dir; `skill-creator` - is now the only bundled skill. README/docs point users to - `dsagt skills add genesis` for it. -- Tests: `test_skill_discovery.py` (15), plus `setup_skills` and - attribution-capture coverage in `test_skills_catalog.py`. **228 passed / - 13 skipped** across all affected suites; ruff + black clean. diff --git a/design-notes/skills-catalog-server-merge.md b/design-notes/skills-catalog-server-merge.md deleted file mode 100644 index a058d06..0000000 --- a/design-notes/skills-catalog-server-merge.md +++ /dev/null @@ -1,177 +0,0 @@ -# Design Note — SkillsCatalog encapsulation + MCP server merge - -**Status:** shipped — checklist items 1–5 done; only the deferred trace-driven -tool audit (item 6) remains. The implementation/refactor plan that follows from -`genesis-skills-comparison.md` §10 (the locked skills-routing model) and the -`latex/skills-routing.png` diagram. See §5 for what's left. - -**Date:** 2026-06-23 - ---- - -## 0. Where this comes from - -`genesis-skills-comparison.md` settled the *conceptual* model (catalog tier vs -installed tier; `SkillRouter` as the single skill-MCP hub; Skills Catalog -abstracting chroma+cache; Skill Directory for installed+created; `skill-creator` -for authoring). This note is the *engineering* plan to make the code match, in -three parts done in sequence. - -Already shipped (Steps A + B, tested): -- **Frontmatter-only catalog indexing** — `index_catalog` embeds `name + - description + tags`, not the SKILL.md body (progressive-disclosure level 1; - avoids embedder truncation + signal dilution; consistent with the keyword - scorer). Description also stored in metadata for clean search summaries. -- **Catalog-only retrieval** — `SkillRouter` searches only `skills_catalog__*` - collections; the keyword fallback scans only the clone cache. Installed skills - are no longer search candidates (they're natively discovered). - ---- - -## 1. Part 1 — `SkillsCatalog` module + B2 cleanup - -**Goal:** encapsulate all catalog logic behind one module; stop the dead -installed-skill indexing. - -- **`SkillsCatalog`** (new module) — *composition over `KnowledgeBase`*, NOT a - new KB and NOT a copy of the vector primitives. It holds a `KnowledgeBase` - handle (the host server's existing instance → shared embedder, no second model - load) + the clone-cache dir, and exposes the skill-domain API: - - `sync(source, *, force)` — clone + frontmatter-index into `skills_catalog__` - - `search(query, *, top_k, tag)` — ChromaDB over catalog collections, keyword - fallback over the cache when no embedder - - `install(name, project_dir)` — copy a catalog skill into the project (+ the - license/PROVENANCE capture already implemented) - - `list_sources()` — KNOWN_SOURCES + synced/indexed view - - The skill-specific behavior (frontmatter indexing, keyword fallback, a skill - `CollectionRoute` preset) lives here; the vector store + embedder are the - shared KB. ``SkillRouter`` becomes a thin render/MCP facade over it. -- **B2 cleanup** — drop the now-dead indexing into the `skills` collection - (`SkillRegistry._index_skill` / `save_skill` / `reindex_all`, and the - `install_skill` re-index). The router no longer searches `skills`, so this is - wasted embed work. (Touches a few `TestSaveSkill` / `test_reindex_all` - assertions.) - -Server-agnostic: `SkillsCatalog` takes whatever KB it's handed, so it drops -straight into the merged server in Part 2 with no rework. - ---- - -## 2. Part 2 — merge the two MCP servers - -**Why:** both `registry_server` and `knowledge_server` already construct their own -`KnowledgeBase` (`registry_server.py:881` + knowledge's `main()`), so the split -is pure duplication — two embedders, two Chroma accesses, and a write-here/ -read-there hazard on `skills_catalog__*` (sync in knowledge, search in registry). -The two-process split buys little isolation: the heavy/risky work is already -offloaded (`run_command` → `dsagt-run` subprocess; `kb_ingest` → job thread). - -Principle: **process = deployment unit; module = concern boundary.** Merge into -one server; keep `KnowledgeBase` / `ToolRegistry` / `SkillRegistry` / -`SkillsCatalog` / provenance as distinct modules behind one tool-dispatch shell. - -**Migration surface (the real cost):** -- Entry points: `dsagt-registry-server` + `dsagt-knowledge-server` → one - `dsagt-server` (`pyproject [project.scripts]`). -- **Per-agent MCP config generation** — every agent writes *two* server entries - (`dsagt-registry`, `dsagt-knowledge`) via `_build_mcp_servers_dict` / - `_mcp_server_args`; collapse to one across all five agents. -- Backward compat: existing projects' configs reference two servers; `dsagt - start` regenerates to one (write_dynamic overwrite handles it — verify). -- Tests: `test_registry_server`, `test_knowledge_server`, `test_config` (agent - config gen), `test_server_startup`. - -**Ups:** one embedder / one Chroma owner / no cross-process collection hazard; -one MCP server per agent (simpler config, faster startup, one `init_tracing`). -**Downs:** all tools share one process (isolation loss — bounded by the -offloading above); the migration churn above. - ---- - -## 3. Part 3 — tool-surface audit (SEPARATE, evidence-based, later) - -Today: **23 MCP tools** (11 registry + 12 knowledge). The agent already sees all -23 (connects to both servers), so the merge is *neutral* on count — proliferation -is pre-existing. - -- **Do NOT collapse into `mode=`/`action=` mega-tools.** A union-schema tool with - a mode enum is harder for a model than distinct, well-named tools (it must pick - tool *and* mode). Clear names are the discovery signal. -- **Do a trace-driven prune.** Every tool call is in MLflow — audit which tools - actually get used across real sessions, then remove/defer dead weight (likely - suspects: `kb_add_vector_db`, `kb_get_suggestions`/`kb_dismiss_suggestion`, - `kb_job_status`, `reconstruct_pipeline`, maybe `kb_append`). -- Treat as a deliberate pass *after* the merge — not guessed now. - ---- - -## 4. Sequence / checklist - -1. [x] `SkillRouter` owns the catalog (compose KB + cache). - - [x] catalog-only search + cache-only keyword fallback (Step B). - - [x] frontmatter-only catalog indexing (Step A). - - [x] `search()` no longer requires a skill registry (catalog needs only KB). - - [x] add `SkillRouter.sync(source)` (→ `sync_source`) + `SkillRouter.install(name, dir)` - (→ `install_into_project`) so the router owns all four ops. - - [x] wire `install_skill` (registry_server) → `router.install`; - `add_skill_source` (knowledge_server) → `router.sync`. -2. [x] B2: drop dead `skills`-collection indexing — removed - `SkillRegistry._index_skill` / `reindex_all` + the `save_skill` index call - + the `install_skill` re-index block. **Also** dropped the bundled-skill - indexing in `setup_core_kb` (same dead `skills` collection — full cut, not - in the original enumerated list but the same waste). `SKILLS_COLLECTION` - kept as a back-compat name only (no reader, no writer). `TestSaveSkill` - assertions were already file-based (only docstrings mentioned indexing); - `test_reindex_all` is `ToolRegistry`, untouched. All four skill suites + - `test_setup_core_kb` green; ruff + black clean. -2b. [x] **`SkillsCatalog` extraction** (folded into this pass). New - `SkillsCatalog(kb, cache_dir)` class in `commands/skills_catalog.py` owns - the catalog data plane — `sync` / `install` / `search(→list[dict])` / - `list_sources` + backend selection (ChromaDB vs keyword fallback). - `SkillRouter` is now a thin render/MCP facade holding a `SkillsCatalog` - (built from `kb`/`cache_dir`, or injected via `catalog=` so the server - shares one). All existing `SkillRouter(kb=…, skill_registry=…)` call sites - + tests unchanged. -3. [x] Merge servers → one `create_dsagt_server` + `dsagt-server` entry point. - Extracted `_registry_tools_and_handlers` / `_knowledge_tools_and_handlers`; - `create_*_server` kept as thin test-facing wrappers; `dsagt_server.py` - composes both `(tools, handlers)` under one `Server("dsagt")` with a - type-dispatched `call_tool` (registry str passthrough + knowledge - dict→json + error wrap) and one shared-KB `main()` (the cross-backend - guard now lives once, in `_build_kb_from_config`). Old per-server `main()`s - deleted; their KB-None registry path is gone (merged server requires a KB, - fails fast on misconfigured `api`). -4. [x] Collapse per-agent MCP config to one `dsagt` entry — `base.py` - (`_mcp_server_args()` no-arg, flat `_DSAGT_MCP_ALWAYS_ALLOW`, - `_build_mcp_servers_dict`), `claude` / `goose` (incl. `--with-extension` - in `interactive_command` + `run_script`) / `codex` / `cline` / - `opencode` (BYOA + proxy). `roo` rides `_build_mcp_servers_dict`. `info.py` - span-source buckets + module docstrings updated. -5. [x] Tests + backward-compat. 10 config-shape tests + `test_info` + smoke-test - comments updated to the single-server shape; new `test_dsagt_server.py` - (23-tool composition + both return-type contracts). Compat is - **rebuild-not-migrate** (README upgrade note + cline `.cline-data` caveat); - no migration code. 338 passed / 13 skipped across affected suites; entry - point re-registered via `uv sync`. -6. [ ] (later) trace-driven tool-surface audit. - -## 5. Current state - -**Checklist items 1–5 complete** (this work spanned two sessions). The skills -refactor + server merge has shipped: one `dsagt-server` process, one shared -`KnowledgeBase`, one MCP entry per agent, `SkillsCatalog` owning the catalog data -plane behind a thin `SkillRouter` facade, and the dead `skills`-collection -indexing fully removed. Backward compat is rebuild-not-migrate (README §"MCP -Server" upgrade note). 338 passed / 13 skipped across affected unit suites; -ruff + black clean; `dsagt-server` re-registered. - -**Only item 6 remains — the trace-driven tool-surface audit (§3), deliberately -deferred.** It needs real MLflow traces across sessions to decide which of the 23 -tools are dead weight; it is *not* a guess-now task. Pick it up when there's -trace data to mine. Likely first suspects (from §3): `kb_add_vector_db`, -`kb_get_suggestions`/`kb_dismiss_suggestion`, `kb_job_status`, -`reconstruct_pipeline`, maybe `kb_append`. - -**Key context to re-load in a fresh session:** this note + `genesis-skills-comparison.md` -§7–§10 (the locked model) + the diagram. The long diagram-iteration history is not -needed. diff --git a/developer.md b/developer.md index 9be7849..75a9009 100644 --- a/developer.md +++ b/developer.md @@ -1,47 +1,41 @@ # DSAgt — Developer Notes -Material that's useful once you start poking at internals or running modes beyond the default `dsagt init` → `dsagt mlflow` → agent flow. +Material that's useful once you start poking at internals or running beyond the default `dsagt init` → agent flow. ## Tests ```bash uv run python -m pytest -m "not integration" # unit tests, no creds required -uv run python -m pytest -m integration -v # integration tests (require .env) +uv run python -m pytest -m integration -v # integration tests (require real credentials / models) ``` -Integration tests read endpoint and key values from `.env` at the repo root. Copy `.env.example` to `.env` and fill in your values. +Integration tests need real `EMBEDDING_*` / `LLM_*` credentials (and, for the episodic-memory judge test, the local GGUF model, downloaded on first run). Copy `.env.example` to `.env` and fill in your values where applicable. -For per-flow hand-tests (CLI, proxy mode, VS Code extensions), see the scripts under [`tests/smoke_test/manual_runs/`](tests/smoke_test/manual_runs/). +For per-flow hand-tests (CLI, VS Code extensions), see the scripts under [`tests/smoke_test/manual_runs/`](tests/smoke_test/manual_runs/). -## Advanced: Proxy mode +## Run model -`dsagt init` followed by `dsagt start --enable-proxy` spawns a LiteLLM proxy in front of your agent's LLM calls. This adds: - -- Full LLM-call traces (request bodies, tool-use blocks, response payloads) in MLflow for agents whose native OTel doesn't emit those payloads (codex, opencode) or that don't emit OTel at all. -- Cache-breakpoint injection on outgoing requests (Anthropic prompt caching). -- Sidechannel detection for agent-internal title-generator / session-namer calls. -- Model-name aliasing — useful when an agent CLI hardcodes a model whitelist incompatible with your gateway's served names (cline, roo). - -Proxy mode reads upstream LLM credentials from `.env` (or the shell). See the prerequisites and the full setup walkthrough at [`tests/smoke_test/manual_runs/proxy_walkthrough.md`](tests/smoke_test/manual_runs/proxy_walkthrough.md). +DSAgt is **BYOA (bring your own agent)**: the agent talks to its own LLM provider directly — DSAgt never interposes on that traffic (there is no proxy). Trace capture instead reads the agent's own on-disk session transcript via the MCP server's in-session heartbeat, so no credentials are required and the trace store stays serverless (a SQLite file per project, no daemon). ## Troubleshooting **Agent command not found.** The agent CLI isn't installed or isn't on PATH — see the install table in the [README](README.md#installation). -**MCP servers not connecting.** Verify uv resolves the server commands: +**MCP server not connecting.** DSAgt exposes a single server (`dsagt-server`; the earlier `dsagt-registry-server` / `dsagt-knowledge-server` pair was merged in 0.2.0). Verify it resolves: ```bash -uv run which dsagt-registry-server -uv run which dsagt-knowledge-server +uv run which dsagt-server ``` -If missing, reinstall: `uv sync --reinstall`. +If missing, reinstall: `uv sync --reinstall`. On an existing project created against the old two-server layout, re-run `dsagt start ` to regenerate its MCP config (for cline, delete `/.cline-data` first). -**MLflow UI empty.** Confirm MLflow is running for the right project: +**No traces / empty MLflow UI.** The store is a serverless SQLite file — there is no daemon. Point the UI at the file and confirm the path: ```bash -dsagt info # shows the pinned port -curl http://localhost: +dsagt info # resolved tracking URI + a session/trace summary +mlflow ui --backend-store-uri sqlite:////mlflow.db ``` +If the file is missing, no session has run in that project yet (the DB is created lazily on the first span). + **Claude keychain conflict.** If `claude` won't authenticate against a non-default gateway, run `claude /logout` to clear the macOS Keychain OAuth, then re-export `ANTHROPIC_BASE_URL` / `ANTHROPIC_API_KEY` and re-launch. diff --git a/docs/architecture.md b/docs/architecture.md index e3d00ba..d416577 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -10,26 +10,28 @@ DSAgt wraps an unmodified agent CLI with four independently-operable layers. The The agent registers CLI tools as markdown files with YAML frontmatter under `/tools/`. DSAgt handles dependency installation via `uv run --with` and wraps every execution with `dsagt-run` for provenance capture. The agent discovers tools via `search_registry`. **Knowledge Base** (`dsagt-server`) -Semantic search over six independently-partitioned ChromaDB collections, served by the same process as the tool registry (one shared embedder, one ChromaDB owner). Three collections are global (populated by `dsagt setup-kb`); three are per-project (filled automatically during use). Background jobs handle long ingest operations. The agent searches via `kb_search`, ingests via `kb_ingest`, and saves user-confirmed facts via `kb_remember`. +Hybrid semantic + BM25 search over six independently-partitioned ChromaDB collections, served by the same process as the tool registry (one shared embedder, one ChromaDB owner). Three collections are machine-wide (provisioned by `dsagt init`, shared across projects); three are per-project (filled automatically during use). Background jobs handle long ingest operations. The agent searches via `kb_search`, ingests via `kb_ingest`, and saves user-confirmed facts via `kb_remember`. Opt-in episodic memory distills each session turn into the per-project `session_memory` collection. **Provenance** (`dsagt-run`) -A thin wrapper around every registered-tool execution. Records the command, arguments, exit code, duration, file counts, and truncated stderr to `/trace_archive/.json` and emits an OTLP span to MLflow. The agent calls `reconstruct_pipeline` to render the trace archive as a reproducible bash script or Snakemake workflow. +A thin wrapper around every registered-tool execution. Records the command, arguments, exit code, duration, file counts, and truncated stderr to `/trace_archive/.json` and emits a `tool.execute` span to the trace store. The agent calls `reconstruct_pipeline` to render the trace archive as a reproducible bash script or Snakemake workflow. -**Observability** (MLflow + OTLP) -MLflow runs locally at a port pinned at `dsagt init` time. All four layers emit OTLP HTTP spans to MLflow's `/v1/traces` endpoint. The agent's own LLM-call traces land in the same store when you export the `OTEL_EXPORTER_OTLP_ENDPOINT` printed by `dsagt init`. +**Observability** (serverless MLflow) +Traces land in a serverless MLflow store — a SQLite file at `/mlflow.db`, with no server or daemon to run. DSAgt emits its own spans live; the agent's LLM-call traces are recovered post-hoc from the on-disk session transcript by the MCP server's in-session heartbeat (no proxy, no OTel routing, no credentials). View with `mlflow ui --backend-store-uri sqlite:////mlflow.db`. ## Project Layout ``` ~/dsagt-projects// - dsagt_config.yaml # project configuration + .dsagt/ # dsagt-internal state (hidden) + config.yaml # project configuration (set by dsagt init) + state.yaml # session log + memory cursor (owned by the MCP server) + explicit_memories.yaml # user-confirmed facts tools/ # registered CLI tool specs (markdown + YAML frontmatter) tools/code/ # agent-written tool scripts skills/ # agent skills (SKILL.md + reference docs) trace_archive/ # tool execution records (JSON, from dsagt-run) - mlflow/ # MLflow traces, metrics, artifacts + mlflow.db # serverless MLflow SQLite trace store kb_index/ # knowledge base vector collections - explicit_memories.yaml # user-confirmed facts # Per-agent runtime config (one of, generated by dsagt init): # claude: CLAUDE.md, .mcp.json @@ -40,4 +42,4 @@ MLflow runs locally at a port pinned at `dsagt init` time. All four layers emit # cline: .clinerules/, cline_mcp_settings.json ``` -Projects are registered in `~/.dsagt/projects.yaml` so `dsagt mlflow ` and `dsagt info ` work from any directory. The data layer is agent-agnostic — re-running `dsagt init --agent ` switches agent platforms while preserving all accumulated knowledge and traces. +Projects are registered in `~/dsagt-projects/projects.yaml` so `dsagt info ` works from any directory. The data layer is agent-agnostic — re-running `dsagt init --agent ` switches agent platforms while preserving all accumulated knowledge and traces. diff --git a/docs/cli.md b/docs/cli.md index d2f8223..f6b56e1 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -2,30 +2,22 @@ All commands are available after [installation](index.md#installation) and activating your virtual environment. -## Project Management - -| Command | Description | -|---------|-------------| -| `dsagt init --agent [--location ] [--mlflow-port N]` | Create a project; write per-agent MCP config; print the launch one-liner | -| `dsagt list` | List all projects with agent, status, and path | -| `dsagt info [--json]` | Resolved config (with source per value) and a session/error summary | -| `dsagt mv ` | Move a project to a new location | -| `dsagt rm [-y] [--keep-files]` | Unregister a project and optionally delete its directory | +DSAgt is **BYOA (bring your own agent)**: the agent talks to its own LLM provider; DSAgt never interposes on that traffic. The trace store is **serverless** (a SQLite file per project) — there is no daemon to start or stop. -## Session Lifecycle +## Project Management | Command | Description | |---------|-------------| -| `dsagt mlflow ` | Start MLflow for a project and print OTel routing exports | -| `dsagt stop ` | Stop the MLflow daemon | -| `dsagt memory --project ` | Distill new traces from MLflow into episodic memory | +| `dsagt init []` | Create or reconfigure a project — interactive: agent platform, location, knowledge collections, skill sources, and the episodic-memory opt-in. Provisions the knowledge base (once per machine) and writes the per-agent instructions + MCP config. Re-runnable as a settings editor. | +| `dsagt init --agent [--location ] [--include … \| --exclude …] [--episodic [--domain-tags "a,b"]]` | Same, non-interactively (scripts/CI). `--include`/`--exclude` pick the KB asset set; `--episodic` enables episodic memory (downloads the ~1 GB local judge on first use). | +| `dsagt start ` | Launch the agent in the project directory (equivalent to `cd && `); on exit, runs post-session catch-up. | +| `dsagt list` | List all projects with agent and path. | +| `dsagt info [--json]` | Resolved config (with the source of each value) and a session/trace summary read from the SQLite store. | +| `dsagt mv ` | Move a project to a new location. | +| `dsagt rm [-y] [--keep-files]` | Unregister a project and optionally delete its directory. | +| `dsagt smoke-test [--agent claude\|goose\|codex\|opencode]` | End-to-end install verification. | -## Setup - -| Command | Description | -|---------|-------------| -| `dsagt setup-kb [--collection ]` | Build the shared core knowledge base collections | -| `dsagt smoke-test [--agent claude\|goose\|codex\|opencode]` | End-to-end install verification | +Skill catalogs are managed **from the agent** via MCP tools (`add_skill_source` / `list_skill_sources` / `search_skills` / `install_skill`), not the CLI. ## Project Location @@ -36,13 +28,19 @@ dsagt init my-project --agent claude --location /data/runs # /data/runs/my-pro dsagt init my-project --agent claude --location . # ./my-project/ ``` +## Viewing traces + +The trace store is a serverless SQLite file — browse it with MLflow's UI pointed at the file (no `dsagt` daemon involved): + +```bash +mlflow ui --backend-store-uri sqlite:////mlflow.db +``` + ## Server Commands -These are launched automatically by `dsagt init` via the per-agent MCP config and are not typically run directly. +These are launched automatically by the per-agent MCP config (and `dsagt start`) and are not typically run directly. | Command | Description | |---------|-------------| -| `dsagt-server` | MCP server — tool registry + knowledge base | -| `dsagt-run` | Provenance-capturing tool execution wrapper | -| `dsagt-proxy` | LiteLLM proxy server (proxy mode only) | -| `dsagt-setup-kb` | Core knowledge base setup (called by `dsagt setup-kb`) | +| `dsagt-server` | The single MCP server — tool registry, knowledge base, memory, and skills. Also runs the in-session heartbeat (trace capture + tool-use/episodic indexing). | +| `dsagt-run` | Provenance-capturing tool execution wrapper; writes execution records to `/trace_archive/`. | diff --git a/docs/developer.md b/docs/developer.md index 09191ae..8b3703d 100644 --- a/docs/developer.md +++ b/docs/developer.md @@ -1,46 +1,43 @@ # Developer Guide -Material for contributors and users who are working beyond the default `dsagt init` → `dsagt mlflow` → agent flow. +Material for contributors and users working beyond the default `dsagt init` → agent flow. ## Tests ```bash uv run python -m pytest -m "not integration" # unit tests, no creds required -uv run python -m pytest -m integration -v # integration tests (require .env) +uv run python -m pytest -m integration -v # integration tests (require real credentials / models) ``` -Integration tests read endpoint and key values from `.env` at the repo root. Copy `.env.example` to `.env` and fill in your values. +Integration tests need real `EMBEDDING_*` / `LLM_*` credentials (and, for the episodic-memory judge test, the local GGUF model, downloaded on first run). Copy `.env.example` to `.env` and fill in your values where applicable. -For per-flow hand-tests (CLI, proxy mode, VS Code extensions), see the scripts under [`tests/smoke_test/manual_runs/`](https://github.com/AI-ModCon/dsagt/tree/main/tests/smoke_test/manual_runs/). +For per-flow hand-tests (CLI, VS Code extensions), see the scripts under [`tests/smoke_test/manual_runs/`](https://github.com/AI-ModCon/dsagt/tree/main/tests/smoke_test/manual_runs/). -## Proxy Mode +## Run model -`dsagt init` followed by `dsagt start --enable-proxy` spawns a LiteLLM proxy in front of your agent's LLM calls. This adds: - -- Full LLM-call traces (request bodies, tool-use blocks, response payloads) in MLflow for agents whose native OTel does not emit those payloads (codex, opencode). -- Cache-breakpoint injection on outgoing requests (Anthropic prompt caching). -- Sidechannel detection for agent-internal title-generator / session-namer calls. -- Model-name aliasing — useful when an agent CLI hardcodes a model whitelist incompatible with your gateway's served names (cline, roo). - -Proxy mode reads upstream LLM credentials from `.env` or the shell. See [`tests/smoke_test/manual_runs/proxy_walkthrough.md`](https://github.com/AI-ModCon/dsagt/blob/main/tests/smoke_test/manual_runs/proxy_walkthrough.md) for the full setup walkthrough. +DSAgt is **BYOA (bring your own agent)**: the agent talks to its own LLM provider directly — DSAgt never interposes on that traffic (there is no proxy). Trace capture instead reads the agent's own on-disk session transcript via the MCP server's in-session heartbeat, so no credentials are required and the trace store stays serverless (a SQLite file per project). Agent LLM-call history is recovered post-hoc; nothing intercepts the network. ## Troubleshooting **Agent command not found.** The agent CLI is not installed or is not on PATH. See the [supported agents table](index.md#supported-agents). -**MCP server not connecting.** Verify uv resolves the server command: +**MCP server not connecting.** Verify the server command resolves: ```bash uv run which dsagt-server ``` -If missing, reinstall: `pip install --force-reinstall https://github.com/AI-ModCon/dsagt/archive/refs/tags/0.1.0.zip`. +If missing, reinstall: `pip install --force-reinstall "git+https://github.com/AI-ModCon/dsagt.git"`. -**MLflow UI empty.** Confirm MLflow is running for the right project: +**No traces / empty MLflow UI.** The store is a serverless SQLite file — there is no daemon to start. Point the UI at the file directly and confirm the path: ```bash -dsagt info # shows the pinned port -curl http://localhost: +dsagt info # shows the resolved tracking URI + a session/trace summary +mlflow ui --backend-store-uri sqlite:////mlflow.db ``` +If the file is missing, the agent hasn't run a session in that project yet (the DB is created lazily on the first span). + **Claude keychain conflict.** If `claude` will not authenticate against a non-default gateway, run `claude /logout` to clear the macOS Keychain OAuth token, then re-export `ANTHROPIC_BASE_URL` / `ANTHROPIC_API_KEY` and re-launch. + +**Episodic memory judge won't load.** Tier-1 episodic memory needs `llama-cpp-python` (a core dependency, installed from a prebuilt CPU wheel) and downloads a ~1 GB GGUF on first use. If it fails, the heartbeat falls back to Tier-0 (mechanical, no LLM) — episodic memory still works, just without LLM distillation. Re-run `uv sync` if the wheel is missing. diff --git a/docs/index.md b/docs/index.md index 5f963fc..9041321 100644 --- a/docs/index.md +++ b/docs/index.md @@ -35,7 +35,9 @@ DSAgt connects an MCP-compatible AI coding agent to tool registration, a semanti Clone the repo and use `uv` (editable install; add `--all-groups` for the test suite): ```bash -pip install https://github.com/AI-ModCon/dsagt/archive/refs/tags/0.1.0.zip +git clone https://github.com/AI-ModCon/dsagt.git +cd dsagt && uv sync --all-groups +source .venv/bin/activate ``` ## Key Capabilities @@ -43,10 +45,10 @@ pip install https://github.com/AI-ModCon/dsagt/archive/refs/tags/0.1.0.zip | Layer | What it does | |-------|-------------| | **Tool Registry** | Register CLI tools as markdown specs; the agent discovers and runs them via `search_registry` | -| **Knowledge Base** | Semantic search over indexed document collections (ChromaDB + FAISS) | -| **Provenance** | `dsagt-run` wrapper records every tool execution to `trace_archive/` and MLflow | +| **Knowledge Base** | Hybrid semantic + keyword (BM25) search over indexed ChromaDB collections | +| **Provenance** | `dsagt-run` wrapper records every tool execution to `trace_archive/`; `reconstruct_pipeline` renders it as a runnable script | | **Explicit Memory** | User-confirmed facts persisted to YAML and the knowledge base | -| **Episodic Memory** | Session distillation via outlier detection over MLflow traces | -| **Observability** | Full OTLP tracing to a local MLflow instance | +| **Episodic Memory** | Opt-in: the MCP server distills each session turn into tagged facts via a local LLM judge (recency-weighted retrieval) | +| **Observability** | Serverless MLflow tracing (a per-project SQLite file) — DSAgt's own spans plus agent traces recovered from the on-disk transcript | See the [Quick Start](quickstart.md) to try all of these in a single session. diff --git a/docs/knowledge-base.md b/docs/knowledge-base.md index 1d47afc..89c6118 100644 --- a/docs/knowledge-base.md +++ b/docs/knowledge-base.md @@ -1,40 +1,37 @@ # Knowledge Base -DSAgt maintains six independently-partitioned ChromaDB collections. The first three are global (under `~/.dsagt/kb_index/`, populated by `dsagt setup-kb`); the last three are per-project (under `/kb_index/`, populated automatically during use). +DSAgt maintains six independently-partitioned ChromaDB collections. The first three are machine-wide (built once under `~/dsagt-projects/kb_index/` and copied into each project); the last three are per-project (under `/kb_index/`, populated automatically during use). All are provisioned/used through `dsagt init` and the agent's MCP tools — there is no separate setup command. ## Collections | Collection | Source | Populated by | |---|---|---| -| **Tool Specs** | Bundled CLI tool specs in `src/dsagt/tools/` | `dsagt setup-kb` | -| **Skills Catalog** | Installable skills from external repos (one `skills_catalog__` collection per source), frontmatter-indexed | `dsagt setup-kb` (default source) + `add_skill_source` | -| **Domain Knowledge** | NeMo Curator + AIDRIN reference corpora; user-ingested docs | `dsagt setup-kb` + agent's `kb_ingest` | -| **Explicit Memory** | User-confirmed facts | Agent's `kb_remember` (also written to `/explicit_memories.yaml`) | -| **Episodic Memory** | Distilled facts from MLflow traces | `dsagt memory --project ` | -| **Tool Use Records** | `dsagt-run` execution traces | `dsagt-run` wrapper writes JSON to `/trace_archive/`; indexed by `dsagt memory` | +| **Tool Specs** | Bundled CLI tool specs in `src/dsagt/tools/` | `dsagt init` (always provisioned) | +| **Skills Catalog** | Installable skills from external repos (one `skills_catalog__` collection per source), frontmatter-indexed | `dsagt init` (chosen sources) + `add_skill_source` | +| **Domain Knowledge** | NeMo Curator + AIDRIN reference corpora; user-ingested docs | `dsagt init` (chosen collections) + agent's `kb_ingest` | +| **Explicit Memory** | User-confirmed facts | Agent's `kb_remember` (also written to `/.dsagt/explicit_memories.yaml`) | +| **Episodic Memory** (`session_memory`) | Distilled session facts | The MCP server's in-session heartbeat (opt-in; see below) | +| **Tool Use Records** (`tool_use`) | `dsagt-run` execution traces | `dsagt-run` writes JSON to `/trace_archive/`; the heartbeat embeds them incrementally (idempotent) | ## Explicit Memory -Explicit memories are facts the user confirms during a session. The agent saves them via `kb_remember`, which writes to both the ChromaDB collection and `/explicit_memories.yaml`. The agent fetches them via `kb_get_memories` on demand (typically when you ask it to recall something) — they are not auto-loaded at session start. +Explicit memories are facts the user confirms during a session. The agent saves them via `kb_remember`, which writes to both the ChromaDB collection and `/.dsagt/explicit_memories.yaml`. The agent fetches them via `kb_get_memories` on demand (typically when you ask it to recall something) — they are not auto-loaded at session start. If the vector store is unavailable, explicit memory degrades to pure-YAML. ## Episodic Memory -`dsagt memory --project ` distills new traces from the project's MLflow store into episodic memory using per-category outlier detection over embedding centroids. Run this after each session to accumulate cross-session memory. +Episodic memory is **opt-in** (`dsagt init --episodic`, off by default). When enabled, the MCP server's in-session heartbeat reads the agent's transcript and distills each completed turn into a few tagged, ≤1-sentence facts in the `session_memory` collection. Two tiers: + +- **Tier-1 (default when enabled)** — a small **local** LLM judge (`Qwen2.5-1.5B`, grammar-constrained JSON) classifies each fact against a closed tag taxonomy (stock "AI-data-ready" tags plus any project `--domain-tags`) and condenses it. Local-by-default: no API key, no cost. The GGUF model (~1 GB) downloads on first use; inference is CPU-side. +- **Tier-0 (fallback)** — mechanical chunk + keyword-tag + embed, no LLM; used automatically if the judge fails, so a turn is never lost. + +Retrieval over `session_memory` is **recency-weighted** (`episodic.recency_half_life_days`, default 14): a newer fact edges out a stale one as a bounded boost, so a corrected fact wins without contradiction detection while durable old facts keep their relevance. Optional per-category outlier detection (`episodic.outlier_sensitivity`) can queue novel facts for review. ## Search The agent searches all collections via `kb_search` and writes via `kb_ingest` / `kb_remember`. Registered tools have their own `search_registry` route over the same backend. Skills are discovered separately — installed ones natively by the agent, installable ones via `search_skills` over the external catalog (see [Tools & Skills](tools-skills.md)). -Hybrid search (dense embeddings + sparse BM25 via Reciprocal Rank Fusion) is on by default per collection route. Cross-encoder reranking is optional. - -## Setup +Hybrid search (dense embeddings + sparse BM25 via Reciprocal Rank Fusion) is on by default per collection. Cross-encoder reranking is optional. The default embedder is a local sentence-transformers model (~130 MB, CPU-side, no API key). -```bash -dsagt setup-kb # all global collections (local embedder) -dsagt setup-kb --collection nemo_curator -dsagt setup-kb --embedding-backend api \ - --embedding-base-url \ - --embedding-api-key -``` +## Provisioning -The Tool Specs collection is wiped and rebuilt on every `setup-kb` run — re-run after upgrading DSAgt to pick up new bundled tools. (Bundled skills are not indexed — agents auto-discover them natively.) +`dsagt init` provisions the KB. The shared, machine-wide collections are built once (the first project on a machine pays the cost) under `~/dsagt-projects/kb_index/`, then copied into each project. `--include` / `--exclude` (asset names, or `all`) select which collections to provision; the bundled Tool Specs collection is always included. Re-running `dsagt init` on an existing project reconfigures it in place. diff --git a/docs/mcp-servers.md b/docs/mcp-servers.md index 4dc922f..f04789a 100644 --- a/docs/mcp-servers.md +++ b/docs/mcp-servers.md @@ -31,6 +31,6 @@ Semantic search and ingestion over indexed document collections. ### Backend -The default embedding backend is local (`sentence-transformers`, CPU-only, no API key needed). Switch to `embedding.backend: api` in `dsagt_config.yaml` to route through a hosted embedder via LiteLLM. Cross-encoder reranking is available via `knowledge.rerank: true`. +The default embedding backend is local (`sentence-transformers`, CPU-only, no API key needed). Switch to `embedding.backend: api` in `.dsagt/config.yaml` to route through a hosted, OpenAI-compatible `/v1/embeddings` endpoint (set `embedding.base_url` and export `EMBEDDING_API_KEY`). Cross-encoder reranking is available via `knowledge.rerank: true`. -Hybrid search (dense + sparse BM25) is on by default and controlled per-route via the `hybrid` flag. +Hybrid search (dense + sparse BM25, fused by Reciprocal Rank Fusion) is always on per collection — it is a property of the store, not a per-call flag. diff --git a/docs/observability.md b/docs/observability.md index 3344a9a..1228c6a 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -1,14 +1,19 @@ # Observability -DSAgt provides end-to-end trace visibility through a local MLflow instance. All internal layers emit OTLP HTTP spans to MLflow's `/v1/traces` endpoint. - -## Starting MLflow +DSAgt logs traces to a **serverless MLflow store** — a SQLite file at `/mlflow.db`. There is no server or daemon to start or stop; the file is created lazily on the first span. View it with MLflow's UI pointed at the file: ```bash -dsagt mlflow +mlflow ui --backend-store-uri sqlite:////mlflow.db ``` -Prints the MLflow UI URL and the `export` block for routing agent OTel output. The port is pinned at `dsagt init` time and listed by `dsagt info `. +`dsagt info ` prints the resolved tracking URI and a session/trace summary. The tracking URI resolves as `MLFLOW_TRACKING_URI` env → project config → the `sqlite:////mlflow.db` default, and never raises. + +## Two feeds + +DSAgt is **BYOA** and does not interpose on the agent's LLM traffic. Traces come from two places: + +1. **First-party spans (live).** DSAgt instruments its own code and emits spans directly to the store as it runs. +2. **Agent traces (post-hoc).** The MCP server's in-session heartbeat reads the agent's own on-disk session transcript, translates it to a canonical trace shape, and writes it to the same store via the MLflow sink — so prompts, responses, and tool calls are recovered without any proxy or credentials. ## Trace Coverage @@ -17,29 +22,18 @@ Prints the MLflow UI URL and the `export` block for routing agent OTel output. T | Knowledge base | `kb.search`, `kb.embed`, `kb.index_search`, `kb.rerank` | Per-phase timing trees | | Tool executions | `tool.execute` | Exit code, duration, file counts, truncated stderr. Full payload in `trace_archive/.json` | | Registry events | `save_tool_spec`, `install_dependencies`, `reconstruct_pipeline` | Span metadata | -| Native agent OTel | LLM call spans | Coverage varies by agent (see below) | - -### Agent OTel Coverage +| Agent traces | one AGENT subtree per turn (`llm` / `tool_` children) | Prompts, responses, tool calls, and token usage where the transcript carries them | -Export the variables printed by `dsagt mlflow` before launching your agent: +### Agent trace coverage -| Agent | Coverage | -|-------|----------| -| claude | Full request/response payloads | -| goose | Full request/response payloads | -| codex | Token counts and tool names | -| opencode | None natively | +Agent traces are reconstructed from each agent's on-disk session record, so coverage no longer depends on the agent emitting OTel. A per-agent reader + translator runs for every supported agent (claude, codex, goose, opencode, cline); claude additionally wires an `mlflow autolog` Stop hook at `dsagt init`. Fidelity is capped by what the transcript persisted (e.g. token counts and timing appear where the agent recorded them). -Every span carries the project's `session.id` for filtering in the MLflow trace view. +Every span carries the project's session id (minted per launch into `/.dsagt/state.yaml`) for filtering in the MLflow trace view. -## Provenance and Reconstruction - -Tool execution records on disk (`trace_archive/.json`) provide the canonical provenance chain. The agent calls `reconstruct_pipeline` to render the archive as a reproducible bash script or Snakemake workflow. +## The heartbeat -## Stopping MLflow +The trace scan runs as a periodic heartbeat inside the long-lived MCP server — the one DSAgt process alive in every launch flow. Each tick reads new transcript records, translates completed turns, and fans out to subscribers (the MLflow sink always; the episodic-memory extractor when enabled). Correctness rests on idempotency: each subscriber keeps its own ack set, so a re-tick or a next-session catch-up can never double-log or lose a turn. The same heartbeat incrementally indexes `dsagt-run` tool-execution records into the `tool_use` collection. -```bash -dsagt stop -``` +## Provenance and Reconstruction -Releases the port and stops the gunicorn workers. The PID is stored in `/.runtime`. +Tool execution records on disk (`trace_archive/.json`) provide the canonical provenance chain. The agent calls `reconstruct_pipeline` to render the archive as a reproducible bash script or Snakemake workflow (which also flushes the latest tool-use records into the searchable index first). diff --git a/docs/quickstart.md b/docs/quickstart.md index 07e09cb..3b0deff 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -1,24 +1,26 @@ # Quick Start -This guide walks through knowledge ingest, tool registration, provenance, and explicit memory using the mock project in [`tests/smoke_test/`](https://github.com/AI-ModCon/dsagt/tree/main/tests/smoke_test/). The examples use `claude`; substitute another agent (`goose`, `codex`, `opencode`) if you prefer — the prompts are agent-agnostic. +This guide walks through knowledge ingest, tool registration, provenance, and explicit memory using the mock project in [`tests/smoke_test/`](https://github.com/AI-ModCon/dsagt/tree/main/tests/smoke_test/). The examples use `claude`; substitute another agent (`goose`, `codex`, `opencode`, `roo`, `cline`) if you prefer — the prompts are agent-agnostic. + +DSAgt is **BYOA**: your agent talks to its own LLM provider. There is **no proxy and no MLflow daemon** — the trace store is a serverless SQLite file per project. ## Setup ```bash -# Install -pip install https://github.com/AI-ModCon/dsagt/archive/refs/tags/0.1.0.zip +# Install (any Python 3.12/3.13 environment) +pip install "git+https://github.com/AI-ModCon/dsagt.git" # Set a convenience variable for the smoke test directory (not a normal dsagt step) export SMOKE_DIR="$(pwd)/tests/smoke_test" -# 1. Create a new project called quickstart +# 1. Create a project called quickstart. Interactive `dsagt init` prompts for the +# agent, location, knowledge collections, skill sources, and episodic memory, +# and provisions the shared knowledge base on first run. --agent makes it +# non-interactive (a ~130 MB local embedder downloads once): dsagt init quickstart --agent claude -# 2. Start MLflow in the background and print the OTel routing exports -dsagt mlflow quickstart - -# 3. Paste the export block from step 2 into this shell, then launch the agent -cd ~/dsagt-projects/quickstart && claude +# 2. Launch the agent from the project directory: +cd ~/dsagt-projects/quickstart && claude # …or: dsagt start quickstart ``` ## Agent Prompts @@ -32,18 +34,6 @@ Inside the agent, paste these prompts one at a time. Replace `$SMOKE_DIR` with t 5. > Put this in explicit memory: samples.csv has null values in the status and timestamp columns. 6. > Tell me what you remember about the samples dataset. -## Teardown - -After exiting the agent, distill the session into episodic memory and stop the MLflow daemon: - -```bash -# Distill traces into episodic memory -dsagt memory --project quickstart - -# Stop the MLflow daemon -dsagt stop quickstart -``` - ## What Was Exercised | Prompt | DSAgt layer | @@ -52,17 +42,20 @@ dsagt stop quickstart | 2 | `dsagt-server` (`save_tool_spec`) — writes `tools/csvcut.md`, etc. | | 3 | `dsagt-run` provenance wrapper — records exec layer to `trace_archive/` | | 4 | KB recall via `kb_search` and registered tool execution | -| 5–6 | Explicit memory (`kb_remember` → `explicit_memories.yaml`) + `kb_get_memories` | +| 5–6 | Explicit memory (`kb_remember` → `.dsagt/explicit_memories.yaml`) + `kb_get_memories` | ## Verify the Artifacts +Exit the agent (`Ctrl+C` or `/exit`), then: + ```bash -dsagt info quickstart +dsagt info quickstart # config + a session/trace summary ls ~/dsagt-projects/quickstart/{tools,trace_archive} -cat ~/dsagt-projects/quickstart/explicit_memories.yaml -``` +cat ~/dsagt-projects/quickstart/.dsagt/explicit_memories.yaml -The MLflow UI URL is printed by `dsagt mlflow quickstart`. +# Traces land in a serverless SQLite store — no server to run. Browse them with: +mlflow ui --backend-store-uri sqlite:///$HOME/dsagt-projects/quickstart/mlflow.db +``` ## Non-Interactive Smoke Test @@ -72,22 +65,22 @@ The same flow runs non-interactively and asserts each artifact is present: dsagt smoke-test --agent claude ``` -## First-Time Knowledge Base Setup +## Knowledge Base Provisioning -`dsagt setup-kb` builds shared ChromaDB collections under `~/.dsagt/kb_index/` that every project on this machine reuses. Run this once after installation. +`dsagt init` provisions the project's knowledge base. The shared, machine-wide collections live under `~/dsagt-projects/kb_index/`, built once (the first project on a machine pays the cost) and copied into each project: -```bash -dsagt setup-kb # all collections (local embedder, no creds) -dsagt setup-kb --collection nemo_curator -dsagt setup-kb --embedding-backend api --embedding-base-url ... --embedding-api-key ... -``` +- **Tool Specs** — DSAgt's bundled tool specs from `src/dsagt/tools/`, always provisioned so the agent finds them via `search_registry` from the first session. +- **Skill Catalogs** — the skill-catalog sources you chose at init (default `genesis`), cloned and frontmatter-indexed so `search_skills` returns installable skills. +- **Knowledge Collections** — optional reference corpora you chose at init (`nemo_curator`, `aidrin`). + +`--include` / `--exclude` (asset names, or `all`) select the set non-interactively. The default embedder is a local sentence-transformers model (~130 MB, CPU-side, no API key). -Three collections are populated: +## Optional: Episodic Memory -- **Tool Specs** — DSAgt's bundled tool specs from `src/dsagt/tools/`, tagged `source: bundled`. -- **Skills Catalog** — the default external skill source (`scientific`), cloned and frontmatter-indexed so `search_skills` has installable skills out of the box. -- **Domain Knowledge** — NeMo Curator and AI Data Readiness Inspector reference corpora. +Pass `--episodic` at init (or choose it in the interactive prompt) to have the MCP server distill each session turn into searchable facts: -The Tool Specs collection is wiped and rebuilt on every run, so re-run `setup-kb` after upgrading DSAgt. (Bundled skills are not indexed — agents auto-discover them natively.) +```bash +dsagt init quickstart --agent claude --episodic --domain-tags "genomics,qc" +``` -The default embedder is a local sentence-transformers model (~130 MB, CPU-only, no API key). Pass `--embedding-backend api` to route through a hosted embedder via LiteLLM. +This downloads a small local LLM judge (~1 GB GGUF) on first use — no API key. See [Knowledge Base → Episodic Memory](knowledge-base.md#episodic-memory). diff --git a/docs/tools-skills.md b/docs/tools-skills.md index 8e218a0..1eafce8 100644 --- a/docs/tools-skills.md +++ b/docs/tools-skills.md @@ -28,7 +28,7 @@ DSAgt wraps every registered tool with `dsagt-run` for provenance capture and `u ### Bundled Tools -DSAgt ships a `scan_directory` tool in `src/dsagt/tools/` that is indexed into the global Tool Specs collection by `dsagt setup-kb`. +DSAgt ships a `scan_directory` tool in `src/dsagt/tools/` that is indexed into the shared Tool Specs collection by `dsagt init` (always provisioned). ## Skills @@ -54,7 +54,7 @@ The diagram's three bands trace a skill's lifecycle: **Discovery** (the router) ### Bundled Skills -DSAgt ships a `skill-creator` skill in `src/dsagt/skills/` (for scaffolding new SKILL.md skills). Bundled and installed skills are **not** indexed for search — every supported agent natively auto-discovers `SKILL.md` folders, so `search_skills` is reserved for the *catalog* tier (skills you can install but haven't yet). Domain skills — including the MODCON `datacard-generator` — are sourced from external catalogs (`dsagt skills add genesis`) rather than bundled, so they stay current upstream. +DSAgt ships a `skill-creator` skill in `src/dsagt/skills/` (for scaffolding new SKILL.md skills). Bundled and installed skills are **not** indexed for search — every supported agent natively auto-discovers `SKILL.md` folders, so `search_skills` is reserved for the *catalog* tier (skills you can install but haven't yet). Domain skills — including the MODCON `datacard-generator` — are sourced from external catalogs (enabled from the agent with `add_skill_source`, or chosen at `dsagt init`) rather than bundled, so they stay current upstream. ### Adding Skills diff --git a/pyproject.toml b/pyproject.toml index bde3b3c..a0f5762 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,8 @@ dynamic = ["version"] description = "DataSmith Agent - AI-assisted data pipeline builder" readme = "README.md" requires-python = ">=3.12,<3.14" -license = {text = "TBD"} +license = "Apache-2.0" +license-files = ["LICENSE"] authors = [ {name = "BASE-DATA Team", email = "aaron.tuor@pnnl.gov"} ] @@ -12,56 +13,39 @@ keywords = ["data", "pipeline", "agent", "mcp", "ai"] dependencies = [ # Core - "pyyaml>=6.0", - "httpx>=0.27.0", - "mcp>=1.0.0", - # LiteLLM is used by MCP servers (knowledge: embeddings), - # session.run_extraction (memory extraction LLM call), and Phase 2 - # ``dsagt start --enable-proxy`` (LiteLLM proxy server bootstrap). - # The ``[proxy]`` extra pulls in backoff / fastapi / uvicorn / ... - # required by ``litellm.proxy.proxy_cli.run_server``. - "litellm[proxy]>=1.83.7", # floor: closes GHSA-r75f / GHSA-v4p8 / GHSA-xqmj — don't downgrade - "python-dotenv>=1.0", # transitive via litellm, pinned directly so a fresh venv always has it - # MCP-server / dsagt-run observability emits OTLP HTTP to MLflow's - # /v1/traces endpoint; floor=3.11.1 so we ship with the latest GenAI - # semconv support and well past the 3.6.0 protobuf-response bug fixed - # in 3.7.0. The proxy-side (_DSAGTMlflowLogger in provenance.py) still - # uses MLflow's MlflowLogger SDK directly, so MLflow remains a hard dep. - "mlflow>=3.11.1,<4.0", - "opentelemetry-api>=1.27", - "opentelemetry-sdk>=1.27", - "opentelemetry-exporter-otlp-proto-http>=1.27", + "pyyaml>=6.0", # config & tool-spec parsing + "httpx>=0.27.0", # HTTP client; API embeddings + "questionary>=2.0", # interactive `dsagt init` select/checkbox menus + "mcp>=1.0.0", # MCP server protocol + "mlflow==3.11.1", # trace store & observability # Knowledge base - "numpy>=1.26", - "llama-index-core>=0.11", - "tree-sitter-language-pack>=0.2", - "chromadb>=1.5.1", - "faiss-cpu>=1.8", - "sentence-transformers==5.4.0", - "transformers==4.47.0", - "llama-index-readers-file>=0.1", - # Sparse keyword retrieval for hybrid search. rank_bm25 is pure Python - # (~10 KB), no native deps; the corpus and IDF stats live in memory and - # are pickled to disk per-collection. Used alongside dense embeddings - # via Reciprocal Rank Fusion in KnowledgeBase.search when the route's - # ``hybrid`` flag is set (default True). - "rank-bm25>=0.2.2", - # csvkit ships ~14 well-behaved CSV CLI tools (csvcut, csvgrep, - # csvstat, csvjson, csvlook, ...). Used by the README quickstart's - # CSV-summary prompts. Replaces the previous ``csvtool`` dep, which - # had inverted exit codes (returned True/False from main(), then - # sys.exit(main()) so every successful run exited 1) — that broke - # agent error-detection on every csvtool call. - "csvkit>=2.2.0", - "h5py>=3.16.0", + "numpy>=1.26", # embedding vector math + "llama-index-core>=0.11", # document & code chunking + "llama-index-readers-file>=0.1", # SimpleDirectoryReader file readers + "tree-sitter-language-pack>=0.2,<1.6", # code-aware chunking (1.6+ rewrite breaks CodeSplitter) + "chromadb>=1.5.1", # vector store + "sentence-transformers==5.4.0", # local embeddings & reranking + "transformers==4.47.0", # pin for sentence-transformers + "rank-bm25>=0.2.2", # sparse keyword retrieval + # Local GGUF judge for episodic Tier-1 distillation (the default Judge + # backend; see memory-plan.md §3 — under BYOA, DSAGT holds no LLM key, so a + # local model is the only zero-config option). Pinned EXACTLY on purpose: + # abetlen's self-hosted wheels are the only prebuilt source (PyPI is + # sdist-only → would compile llama.cpp), and they are intermittently + # zip-corrupt in a way uv hard-rejects (0.3.25/.27/.28/.30 all fail; + # extraction errors like "Bad uncompressed size"). 0.3.29 verified intact + # on macOS arm64, manylinux x86_64, and win_amd64 (2026-06-28) — RE-VERIFY + # integrity across those platforms before bumping this pin. No macOS + # x86_64 wheel exists upstream at any version, so Intel Macs are excluded by + # the marker (no LocalJudge there; Tier-0 mechanical memory still works). + # CPU wheel index + CUDA-HPC opt-in are wired in [tool.uv] below. + "llama-cpp-python==0.3.29; sys_platform != 'darwin' or platform_machine != 'x86_64'", ] [project.scripts] dsagt = "dsagt.commands.cli:main" dsagt-run = "dsagt.commands.run_tool:main" -dsagt-proxy = "dsagt.commands.proxy_server:main" dsagt-server = "dsagt.mcp.server:main" -dsagt-setup-kb = "dsagt.commands.setup_core_kb:main" [dependency-groups] dev = [ @@ -75,7 +59,7 @@ docs = [ ] [build-system] -requires = ["setuptools>=61.0", "wheel"] +requires = ["setuptools>=77.0", "wheel"] build-backend = "setuptools.build_meta" [tool.setuptools] @@ -118,3 +102,22 @@ required-environments = [ constraint-dependencies = [ "numpy<2.0; sys_platform == 'darwin' and platform_machine == 'x86_64'", ] + +# llama-cpp-python ships prebuilt wheels only at this self-hosted index (PyPI +# carries the sdist alone → a source build needing cmake + a C compiler). Point +# uv at the CPU wheel index for this one package so ``uv sync`` fetches a binary +# on macOS arm64, Linux x86_64 (incl. HPC nodes), and Windows amd64. +# +# GPU on CUDA HPC is an opt-in (uv maps a package to one index, and the CPU +# wheel is the cross-platform baseline): reinstall the CUDA build with e.g. +# uv pip install llama-cpp-python==0.3.29 \ +# --index-url https://abetlen.github.io/llama-cpp-python/whl/cu124 +# (match cuNNN to the node's toolkit). CPU inference of a 1.5B judge on the +# batched, background heartbeat is fine, so GPU stays optional. +[[tool.uv.index]] +name = "llama-cpp-cpu" +url = "https://abetlen.github.io/llama-cpp-python/whl/cpu" +explicit = true + +[tool.uv.sources] +llama-cpp-python = { index = "llama-cpp-cpu" } diff --git a/src/dsagt/__init__.py b/src/dsagt/__init__.py index 8337be1..154b57b 100644 --- a/src/dsagt/__init__.py +++ b/src/dsagt/__init__.py @@ -12,11 +12,12 @@ # heavy imports happen. Without this, PyTorch / sentence-transformers / # numpy+MKL default to using every available core, which pegs the # machine and causes visible system unresponsiveness during embed bursts -# (kb_ingest, kb_search bursts, setup-kb model load). Half the physical +# (kb_ingest, kb_search bursts, init's KB build). Half the physical # cores is a sensible default that leaves headroom for the OS, the # agent process, OneDrive sync, IDE, browser, etc. ``setdefault`` # preserves any value the user has already exported in their shell. import os as _os + _default_threads = str(max(1, (_os.cpu_count() or 4) // 2)) _os.environ.setdefault("OMP_NUM_THREADS", _default_threads) _os.environ.setdefault("MKL_NUM_THREADS", _default_threads) diff --git a/src/dsagt/agents/__init__.py b/src/dsagt/agents/__init__.py index 89fa517..d9c45f2 100644 --- a/src/dsagt/agents/__init__.py +++ b/src/dsagt/agents/__init__.py @@ -5,67 +5,41 @@ instructions, env vars) from the single ``dsagt_config.yaml``. Launches the agent process in the foreground and blocks until it exits. -Post-proxy DSAGT: each agent talks directly to its own provider. We -inject MLflow + OTel env vars so every agent's native LLM-call telemetry -lands in the project's MLflow store, but model selection / API keys / -provider base URLs are the user's responsibility. - -Two orthogonal questions about each agent: - -1. **Native visibility.** Without ``--enable-proxy``, are the agent's - LLM calls visible in the MLflow UI (audit, drill-in, search)? -2. **Native extraction.** Without ``--enable-proxy``, does end-of- - session memory extraction work? - -Answer to #2 is uniformly **no for non-proxy paths**, on purpose. Each -agent that emits OTel does so in its own shape (Claude Code: span -events; Goose: domain spans; LiteLLM autolog: ``mlflow.spanInputs`` / -``mlflow.spanOutputs``). Memory extraction reads a single canonical -shape (the LiteLLM-autolog shape, emitted only by ``dsagt-proxy``) so -the parser stays small and we avoid per-agent maintenance forever. -Run with ``--enable-proxy`` to get extraction. - -Native visibility (``otel_payload_support`` class var, per-agent): +BYOA: each agent talks directly to its own provider. DSAGT forces no +telemetry env on the agent — agent LLM-call history is recovered +post-hoc from the agent's on-disk session record, not by native OTel +emission. We set only ``MLFLOW_TRACKING_URI`` (so the MCP servers and +any MLflow client log to the project's store) and per-project state +dirs. Model selection / API keys / provider base URLs are the user's +responsibility. + +The ``otel_payload_support`` class var records each agent's *potential* +native-OTel fidelity — retained as reference for the Phase 2 trace +pipeline, which reads transcripts rather than OTel: ========= ============ ============================================= - Agent Support tier Native MLflow visibility (no proxy) + Agent Support tier Native MLflow visibility ========= ============ ============================================= claude full yes — every turn lands as a trace with messages, response, tool_use blocks (gated by 4 ``CLAUDE_CODE_*`` / ``OTEL_LOG_*`` flags we set) - goose full yes — ``dispatch_tool_call`` span carries - tool + arguments JSON + goose full yes — native OTel ``dispatch_tool_call`` span + (not consumed; no hook → no DSAGT autolog/memory) codex partial limited — Codex OTel spans carry only token - counts and tool names; full conversation - lives in ``$CODEX_HOME/sessions/rollout-*.jsonl`` - (side-channel reader not yet wired) + counts and tool names cline none no — Cline emits no OTel spans at all; the agent is a black box from DSAgt's view - roo none no — Roo Code imports zero OTel SDKs; - PostHog telemetry is payload-free + opencode none no — no OTel wired in by default ========= ============ ============================================= -Pick the run mode by what you need: - -* **Visibility only** (Claude Code / Goose): default ``dsagt start`` - is enough. Real-time audit + drill-in works in the MLflow UI. - Memory extraction will produce nothing. -* **Visibility + extraction** (any agent): ``dsagt start - --enable-proxy``. Adds one subprocess; routes every LLM call - through it; lands traces in canonical shape that extraction reads. -* **Visibility for non-emitters** (Cline / Roo / Codex partial): - ``dsagt start --enable-proxy`` is the only way to see the agent's - LLM calls at all. Extraction is a free downstream consequence. - Tool execution provenance (``dsagt-run`` ``tool.execute`` spans) and KB observability (``kb.*`` / ``registry.*`` spans from MCP servers) always -work via OTLP regardless of run mode — the proxy decision affects only -the agent's own LLM-call traces. +work via OTLP — independent of the agent's own LLM-call traces. Each agent's quirks live in its own module — see ``base.py`` for the :class:`AgentSetup` ABC and one of the subclass modules -(``claude.py``, ``goose.py``, ``cline.py``, ``roo.py``, ``codex.py``) +(``claude.py``, ``goose.py``, ``cline.py``, ``codex.py``, ``opencode.py``) for the platform-specific details and the per-agent investigation behind its support tier. @@ -92,14 +66,12 @@ _build_mcp_servers_dict, _mcp_env_block, _mcp_server_args, - _PROXY_FORWARDED_SENTINEL, ) from .claude import ClaudeSetup from .cline import ClineSetup from .codex import CodexSetup, _render_codex_config from .goose import GooseSetup from .opencode import OpenCodeSetup -from .roo import RooSetup logger = logging.getLogger(__name__) @@ -112,7 +84,6 @@ ClaudeSetup, GooseSetup, ClineSetup, - RooSetup, CodexSetup, OpenCodeSetup, ) @@ -120,26 +91,15 @@ AGENTS: dict[str, type[AgentSetup]] = {cls.name: cls for cls in _AGENT_CLASSES} -def _setup_for(agent_name: str, proxy_port: int | None = None) -> AgentSetup: +def _setup_for(agent_name: str) -> AgentSetup: """Return a fresh :class:`AgentSetup` instance for ``agent_name``. - *proxy_port*, when set, is forwarded to the constructor so the - instance binds its proxy-mode methods (``write_dynamic`` → - ``proxy_write_dynamic``, ``run_script`` → ``proxy_run_script``). - Callers don't have to know about the dispatch — they always call - ``setup.write_dynamic(...)`` / ``setup.run_script(...)``. - Raises ``KeyError`` with a helpful message if the agent isn't registered. """ cls = AGENTS.get(agent_name) if cls is None: raise KeyError(f"Unknown agent {agent_name!r}. Registered: {sorted(AGENTS)}.") - return cls(proxy_port=proxy_port) - - -def _proxy_port_from_config(config: dict) -> int | None: - """Extract the active proxy port from a config dict, if any.""" - return (config.get("proxy") or {}).get("port") + return cls() # --------------------------------------------------------------------------- @@ -154,18 +114,14 @@ def agent_env(config: dict) -> dict: 1. ``os.environ`` (user's shell env). 2. DSAGT-wide vars (``DSAGT_PROJECT``, ``DSAGT_PROJECT_DIR``, ``DSAGT_AGENT``, ``DSAGT_SESSION_ID``). - 3. MLflow + OTel telemetry env (``MLFLOW_TRACKING_URI``, - ``OTEL_EXPORTER_OTLP_ENDPOINT``, ``OTEL_EXPORTER_OTLP_HEADERS``, - ``OTEL_RESOURCE_ATTRIBUTES``) so the agent's native OTel SDK and - the MCP servers' ``init_tracing`` both land traces in the same - MLflow experiment. - 4. Per-agent overrides via :meth:`AgentSetup.env_overrides` — - each setup class owns its quirks: claude code's verbosity - flags, goose's ``GOOSE_PROVIDER``/``GOOSE_MODEL`` selectors, - translation of ``llm.{api_key,base_url}`` into the env-var - names that agent's runtime reads, codex/cline state-dir env, etc. - 5. Proxy overrides (when ``--enable-proxy`` set) — last so they - win over per-agent provider env. + 3. ``MLFLOW_TRACKING_URI`` so the MCP servers' ``init_tracing`` and + any MLflow client running under the agent log to the project's + store. No OTel routing env — DSAGT does not force native agent + telemetry; agent traces are recovered post-hoc from the on-disk + transcript. + 4. Per-agent dsagt-owned runtime env via + :meth:`AgentSetup.runtime_env` — per-project state dirs only + (``CLINE_DIR``, ``CODEX_HOME``). """ pdir = config["project_dir"] agent_name = config["agent"] @@ -178,131 +134,52 @@ def agent_env(config: dict) -> dict: if config.get("session_id"): env["DSAGT_SESSION_ID"] = config["session_id"] - mlflow_port = config.get("mlflow", {}).get("port") - proxy_port_for_otel = (config.get("proxy") or {}).get("port") - if mlflow_port: - mlflow_url = f"http://localhost:{mlflow_port}" - env["MLFLOW_TRACKING_URI"] = mlflow_url - # Claude in BYOA mode (no proxy) uses ``mlflow autolog claude`` - # for agent-side traces — its Stop hook produces richer - # transcript-based traces than native OTel. Skip OTel routing - # so we don't get duplicate (and inferior) trace shapes. MCP - # servers and dsagt-run still get tracing because their - # ``init_tracing`` reads MLflow URL from cwd's ``dsagt_config.yaml``, - # not these env vars. - skip_otel_for_claude_byoa = agent_name == "claude" and not proxy_port_for_otel - if not skip_otel_for_claude_byoa: - # OTel endpoint for the agent's native telemetry SDK. The - # x-mlflow-experiment-id header is mandatory for MLflow's OTLP - # receiver — resolve to the experiment's numeric id once at - # startup; if MLflow can't be reached yet we still write the env - # vars so init_tracing can resolve later. - env["OTEL_EXPORTER_OTLP_ENDPOINT"] = f"{mlflow_url}/v1/traces" - experiment_id = _resolve_experiment_id(mlflow_url, config["project"]) - if experiment_id: - env["OTEL_EXPORTER_OTLP_HEADERS"] = ( - f"x-mlflow-experiment-id={experiment_id}" - ) - # Resource attributes flow onto every span emitted by the agent's - # OTel SDK. ``session.id`` is what MLflow's OTLP receiver - # promotes to ``mlflow.trace.session`` trace_metadata — required - # so end-of-session memory extraction can find this run's traces. - resource_attrs = [f"service.name={agent_name}"] - if config.get("session_id"): - resource_attrs.append(f"session.id={config['session_id']}") - env["OTEL_RESOURCE_ATTRIBUTES"] = ",".join(resource_attrs) - - pre_runtime_env = dict(env) - # BYOA: only dsagt-owned env (telemetry capture flags, per-project - # state dirs). Provider credentials live in the user's shell; - # ``env_overrides`` is reserved for Phase 2 proxy mode. + from dsagt.observability import resolve_tracking_uri + + env["MLFLOW_TRACKING_URI"] = resolve_tracking_uri(config) + + # BYOA: only dsagt-owned env (per-project state dirs). DSAGT forces + # no telemetry env on the agent — agent traces are recovered post-hoc + # from the on-disk transcript. Provider credentials live in the + # user's shell. env.update(setup.runtime_env(config)) # Transparency: surface what credential env vars the agent will # actually pick up from the user's shell, so a missing or wrong # value isn't silently consumed. - proxy_port = (config.get("proxy") or {}).get("port") - if not proxy_port: - _warn_on_preconfigured_creds(setup, env, pre_runtime_env) - - # Opt-in proxy routing (Phase 2). When ``--enable-proxy`` populated - # ``config["proxy"]["port"]``, delegate to the agent's proxy hooks. - # ``proxy_env_overrides`` sets the proxy URL; ``env_overrides`` is - # the per-agent llm-config translation hook the proxy path will use. - if proxy_port: - env.update(setup.env_overrides(config)) - env.update(setup.proxy_env_overrides(proxy_port)) + _warn_on_preconfigured_creds(setup, env) return env -def _warn_on_preconfigured_creds( - setup: AgentSetup, - env: dict, - pre_overrides_env: dict, -) -> None: - """Emit a one-line transparency warning when our env_overrides - didn't populate any of the agent's credential env vars but the - user's shell did. +def _warn_on_preconfigured_creds(setup: AgentSetup, env: dict) -> None: + """Emit a one-line transparency note listing which of the agent's + credential env vars are present in the user's shell. Lists the var NAMES (never values — keys are secrets). Helps the - user verify what the agent will actually pick up when the project - YAML is empty or has unresolved ``${VAR}`` placeholders. - - Skipped when the proxy is enabled (proxy_env_overrides plants - everything we need) and when the agent has no credential env vars - declared (IDE-only agents). + user verify what the agent will actually pick up. No-op for agents + with no credential env vars declared (IDE-only agents). """ if not setup.credential_env_vars: return - # What did our env_overrides actually inject? - injected = { - k - for k in setup.credential_env_vars - if env.get(k) and env.get(k) != pre_overrides_env.get(k) - } - if injected: - return # Project YAML supplied credentials; nothing to warn about. - - # Nothing injected — list the vars present from the user's shell. from_shell = [k for k in setup.credential_env_vars if env.get(k)] if from_shell: logger.warning( - "%s: project config has no llm credentials — agent will use " - "preconfigured env vars: %s", + "%s: agent will use preconfigured env vars from the shell: %s", setup.name, ", ".join(from_shell), ) else: logger.warning( - "%s: project config has no llm credentials and none of %s are " - "set in the shell — agent may fall back to its own auth flow " - "(claude.ai subscription, codex login, etc.) or fail at first call.", + "%s: none of %s are set in the shell — agent may fall back to " + "its own auth flow (claude.ai subscription, codex login, etc.) " + "or fail at first call.", setup.name, ", ".join(setup.credential_env_vars), ) -def _resolve_experiment_id(mlflow_url: str, project_name: str) -> str | None: - """Look up the MLflow experiment id for *project_name*; create if absent. - - Returns None when MLflow isn't reachable yet — agent_env writes the - rest of the OTel block anyway and ``init_tracing`` retries on the - server side. We keep this best-effort because dsagt-launch can race - against MLflow startup; observability.py's _resolve_experiment_id - runs again per-process and will succeed once MLflow is up. - """ - try: - import mlflow - - mlflow.set_tracking_uri(mlflow_url) - return str(mlflow.set_experiment(project_name).experiment_id) - except Exception as e: - logger.debug("could not resolve experiment id at agent_env time: %s", e) - return None - - def agent_command(config: dict) -> list[str]: """Return the shell command to launch the agent interactively.""" return _setup_for(config["agent"]).interactive_command(config) @@ -344,32 +221,26 @@ def dynamic_agent_record( env: dict, working_dir: str | Path, ) -> list[str]: - """Write the agent's runtime-dependent files: MCP config + ``.dsagt_env`` - + ``dsagt-launch.sh``. + """Write the agent's runtime-dependent files: the per-agent MCP config. Caller must have already: - Resolved the agent and stored it in ``config["agent"]`` - - Started MLflow and updated ``config["mlflow"]["port"]`` to the - actually-bound port - Built ``env`` via :func:`agent_env` - """ - from .base import _write_launch_shim - setup = _setup_for(config["agent"], proxy_port=_proxy_port_from_config(config)) + No launch shim is written — ``dsagt init`` collapses to config + + instructions + MCP config; the user starts the agent directly in the + project dir or via ``dsagt start``. + """ + setup = _setup_for(config["agent"]) actions = setup.write_dynamic( config, env, Path(working_dir), Path(config["project_dir"]), ) - # Mirror installed skills into the agent's native skills dir (all agents, - # both modes). Central here so each agent only declares native_skills_dir. + # Mirror installed skills into the agent's native skills dir (all agents). + # Central here so each agent only declares native_skills_dir. actions += setup.setup_skills(Path(working_dir), config) - # Launch shim is BYOA-only. Skip when proxy mode is active — - # ``dsagt start --enable-proxy`` is the only sensible entry point - # in that mode (proxy URL must be plumbed through agent env). - if not _proxy_port_from_config(config): - actions.append(_write_launch_shim(setup, config, Path(working_dir))) return actions @@ -382,7 +253,7 @@ def launch_agent( ) -> int: """Launch the agent in the foreground. Blocks until it exits.""" working_dir = Path(working_dir) - setup = _setup_for(config["agent"], proxy_port=_proxy_port_from_config(config)) + setup = _setup_for(config["agent"]) if script_path is not None: return setup.run_script( @@ -418,6 +289,5 @@ def launch_agent( "_build_mcp_servers_dict", "_mcp_env_block", "_mcp_server_args", - "_PROXY_FORWARDED_SENTINEL", "_render_codex_config", ] diff --git a/src/dsagt/agents/base.py b/src/dsagt/agents/base.py index 928e114..e32899d 100644 --- a/src/dsagt/agents/base.py +++ b/src/dsagt/agents/base.py @@ -12,7 +12,6 @@ import json import logging -import shlex import shutil import subprocess from abc import ABC, abstractmethod @@ -32,10 +31,10 @@ # files between init and start without losing edits on the next start. _DSAGT_MARKER = "DSAgt Pipeline Builder" -# Tools the dsagt MCP server exposes — listed in ``alwaysAllow`` so roo -# and cline auto-approve them without a human-in-the-loop prompt. Keep in +# Tools the dsagt MCP server exposes — listed in ``alwaysAllow`` so cline +# auto-approves them without a human-in-the-loop prompt. Keep in # sync with the ``mcp/*_tools.py`` tool registrations (registry / knowledge / -# memory / skill); a tool added there but not here means roo/cline will hang on +# memory / skill); a tool added there but not here means cline will hang on # its first call. All dsagt MCP tools live behind the single ``dsagt-server``, # so the always-allow list is one flat union. _DSAGT_MCP_ALWAYS_ALLOW = [ @@ -44,7 +43,6 @@ "http_request", "install_dependencies", "install_skill", - "kb_add_vector_db", "kb_append", "kb_dismiss_suggestion", "kb_get_memories", @@ -65,68 +63,6 @@ ] -# Sentinel API key planted in agent / MCP-child env when the optional -# proxy is enabled, so any direct call that bypasses the proxy returns -# 401 fast — fails loudly instead of silently leaking around our -# observability pipeline. -_PROXY_FORWARDED_SENTINEL = "dsagt-proxy-forwarded-disable-direct-calls" - - -def _real(value) -> str | None: - """Return *value* trimmed, or ``None`` if blank or an unresolved - ``${VAR}`` interpolation. Centralized so each agent's - ``env_overrides`` filters config values consistently — we never - propagate a placeholder to a downstream env var. - """ - s = (value or "").strip() if isinstance(value, str) else "" - if not s or s.startswith("${"): - return None - return s - - -def _anthropic_env(llm: dict) -> dict[str, str]: - """Anthropic-protocol env vars from ``llm.*`` config. - - Returns ``{}`` unless ``llm.provider == "anthropic"`` — never - propagates anthropic vars on top of an openai-shaped upstream, - where they'd be meaningless or actively misleading. - - Called only by agent setups whose native runtime speaks anthropic - (claude code), and by multi-protocol agents (goose, cline, roo) - when the configured provider matches. - """ - if (_real(llm.get("provider")) or "").lower() != "anthropic": - return {} - out: dict[str, str] = {} - if api_key := _real(llm.get("api_key")): - out["ANTHROPIC_API_KEY"] = api_key - if base_url := _real(llm.get("base_url")): - out["ANTHROPIC_BASE_URL"] = base_url - if model := _real(llm.get("model")): - out["ANTHROPIC_MODEL"] = model - return out - - -def _openai_env(llm: dict) -> dict[str, str]: - """OpenAI-protocol env vars from ``llm.*`` config. - - Returns ``{}`` unless ``llm.provider`` is one of the OpenAI-wire - aliases (``openai``, ``openai_like``, ``azure``). Only called by - agents whose native runtime speaks the OpenAI wire protocol (codex, - LiteLLM-backed paths) or by multi-protocol agents (goose, cline, - roo) when the configured provider matches. - """ - provider = (_real(llm.get("provider")) or "").lower() - if provider not in ("openai", "openai_like", "azure"): - return {} - out: dict[str, str] = {} - if api_key := _real(llm.get("api_key")): - out["OPENAI_API_KEY"] = api_key - if base_url := _real(llm.get("base_url")): - out["OPENAI_BASE_URL"] = base_url - return out - - # --------------------------------------------------------------------------- # Functional helpers (provider-agnostic) # --------------------------------------------------------------------------- @@ -144,16 +80,27 @@ def _mcp_server_args() -> list[str]: def _mcp_env_block(config: dict) -> dict[str, str]: """Env vars the dsagt MCP server children need at startup. - Project routing (project name, project_dir, mlflow port) lives in - ``dsagt_config.yaml`` and is read by services via cwd-walk — single - source of truth, no env duplication. This block carries only the - embedding-backend settings, which the embedding client still reads - from env (refactor TBD). For ``backend: api`` the user must set - ``EMBEDDING_API_KEY`` in their shell env (creds never on disk). + Benign routing only (no credentials, no provider redirection): the + project name + dir, the serverless ``MLFLOW_TRACKING_URI``, and the + embedding-backend settings. MCP children run with cwd == project_dir + and could read most of this from ``.dsagt/config.yaml``, but agents that + don't inherit the parent's shell env into their MCP children (codex / + cline) need it baked into the per-agent MCP config. For + ``backend: api`` the user still sets ``EMBEDDING_API_KEY`` in their shell + (creds never on disk). + + No session id here — the MCP server mints it at startup into + ``.dsagt/state.yaml`` (it owns the session lifecycle now), so there's + nothing to thread through the env. """ + from dsagt.observability import resolve_tracking_uri + emb = config.get("embedding") or {} block: dict[str, str] = {} for key, src in ( + ("DSAGT_PROJECT", config.get("project")), + ("DSAGT_PROJECT_DIR", config.get("project_dir")), + ("MLFLOW_TRACKING_URI", resolve_tracking_uri(config)), ("EMBEDDING_BACKEND", emb.get("backend")), ("EMBEDDING_MODEL", emb.get("model")), ("EMBEDDING_BASE_URL", emb.get("base_url")), @@ -171,29 +118,6 @@ def _load_master_instructions() -> str | None: return None -def _format_roomodes(instructions: str) -> str: - """Wrap master instructions as a Roo Code .roomodes JSON file.""" - parts = instructions.split("\n## ", 1) - role = parts[0].strip() - custom = ("## " + parts[1]).strip() if len(parts) > 1 else "" - - return json.dumps( - { - "customModes": [ - { - "slug": "dsagt", - "name": "DSAgt Pipeline Builder", - "roleDefinition": role, - "customInstructions": custom, - "groups": ["read", "edit", "browser", "command", "mcp"], - "source": "project", - } - ] - }, - indent=2, - ) - - def _append_or_write(path: Path, content: str, marker: str) -> str | None: """Idempotent write for instructions files. @@ -293,8 +217,8 @@ def _mirror_skills_to(target_dir: Path, skill_dirs: list[Path]) -> list[str]: def _build_mcp_servers_dict(env_block: dict | None) -> dict: """Build the standard ``{"mcpServers": {...}}`` dict for the dsagt server. - Used by agents that load MCP config from a JSON file (roo via - ``.roo/mcp.json``). Claude Code uses the same shape via ``.mcp.json`` + Used by agents that load MCP config from a JSON file. Claude Code + uses this shape via ``.mcp.json`` but builds it inline in :class:`ClaudeSetup.write_dynamic`. Cline doesn't use this — it requires ``cline mcp add`` to register the server. """ @@ -341,114 +265,6 @@ def _run_simple_script( return 0 -# --------------------------------------------------------------------------- -# Launch shim — dsagt-launch.sh -# --------------------------------------------------------------------------- - - -def _render_launch_shim(setup: AgentSetup, config: dict) -> str: - """Render the dsagt-launch.sh shell script for a project. - - The shim is the BYOA "transparency" entry point — users either run it - directly (``bash dsagt-launch.sh``), or read it and execute the lines - manually. It does NOT exec the agent — instead it prints how to - launch (CLI / VS Code), letting the user pick. - - Steps: - 1. Start MLflow in the background if not already running. - 2. Resolve the experiment-id for this project at run time - (curl MLflow's REST API). - 3. Export OTel routing env (skipped for Claude — mlflow autolog - claude's ``.claude/settings.json`` handles agent-side traces). - 4. Export per-agent telemetry verbosity flags. - 5. Print available agent-launch options. - """ - project = config["project"] - mlflow_port = (config.get("mlflow") or {}).get("port") - agent_name = setup.name - pdir = config.get("project_dir") or "." - - cli_cmd = " ".join(shlex.quote(p) for p in setup.interactive_command({})) - vscode_lines = setup.vscode_hint(Path(str(pdir))) or [] - - # Claude in BYOA gets agent-side traces via mlflow autolog claude's - # Stop hook, so we omit the OTel routing block (would create - # duplicate, lower-fidelity traces alongside the rich transcript). - skip_otel_routing = agent_name == "claude" - - lines: list[str] = [] - lines.append("#!/usr/bin/env bash") - lines.append("# dsagt-launch.sh — start MLflow, set env, show launch options.") - lines.append( - "# Generated by `dsagt init`. Re-running `dsagt init` overwrites this file." - ) - lines.append("set -euo pipefail") - lines.append('cd "$(dirname "$0")"') - lines.append("") - lines.append("# 1. Start MLflow in the background if not already running.") - lines.append(f"dsagt mlflow {shlex.quote(project)} --background-only") - lines.append("") - - if not skip_otel_routing and mlflow_port: - lines.append("# 2. Resolve experiment id for OTel routing.") - lines.append( - f"EXPERIMENT_ID=$(curl -s " - f'"http://localhost:{mlflow_port}/api/2.0/mlflow/experiments/get-by-name?experiment_name={project}" ' - '| python3 -c \'import json,sys; print(json.load(sys.stdin)["experiment"]["experiment_id"])\')' - ) - lines.append("") - lines.append( - "# 3. OTel routing env (agent's native OTel SDK ships traces to MLflow)." - ) - lines.append(f'export MLFLOW_TRACKING_URI="http://localhost:{mlflow_port}"') - lines.append("export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf") - lines.append( - f'export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT="http://localhost:{mlflow_port}/v1/traces"' - ) - lines.append( - 'export OTEL_EXPORTER_OTLP_HEADERS="x-mlflow-experiment-id=$EXPERIMENT_ID"' - ) - lines.append(f'export OTEL_RESOURCE_ATTRIBUTES="service.name={agent_name}"') - lines.append("") - elif mlflow_port: - lines.append( - "# Claude uses `mlflow autolog claude` (.claude/settings.json) for" - ) - lines.append("# agent-side traces — no OTel routing needed.") - lines.append(f'export MLFLOW_TRACKING_URI="http://localhost:{mlflow_port}"') - lines.append("") - - if setup.telemetry_env: - lines.append("# 4. Agent telemetry verbosity flags.") - for k, v in setup.telemetry_env.items(): - lines.append(f"export {k}={shlex.quote(v)}") - lines.append("") - - lines.append("# 5. Show how to launch the agent — pick one.") - lines.append('echo ""') - lines.append('echo "Environment ready. Launch the agent in one of these ways:"') - lines.append('echo ""') - lines.append(f'echo " CLI: {cli_cmd}"') - if vscode_lines: - for hint in vscode_lines: - lines.append(f'echo " VS Code: {hint}"') - lines.append('echo ""') - lines.append('echo "Run any of the above in this shell."') - lines.append("") - return "\n".join(lines) - - -def _write_launch_shim(setup: AgentSetup, config: dict, working_dir: Path) -> str: - """Write ``dsagt-launch.sh`` to working_dir and chmod it executable. - - Returns a one-line action description for the init output. - """ - shim_path = working_dir / "dsagt-launch.sh" - shim_path.write_text(_render_launch_shim(setup, config)) - shim_path.chmod(0o755) - return f"Wrote {shim_path}" - - # --------------------------------------------------------------------------- # AgentSetup ABC # --------------------------------------------------------------------------- @@ -482,66 +298,30 @@ class AgentSetup(ABC): static_marker: ClassVar[str] install_hint: ClassVar[str] = "Install the agent CLI first." - def __init__(self, proxy_port: int | None = None): - """Rebind ``write_dynamic`` / ``run_script`` to their proxy-mode - analogs (``proxy_write_dynamic`` / ``proxy_run_script``) when - Phase 2 proxy mode is active AND the subclass overrides them. - - Encapsulates the dispatch so callers always say ``setup.write_dynamic(...)`` - without knowing whether the BYOA or proxy implementation runs. - - Why the override check: the base default ``proxy_write_dynamic`` - delegates to ``self.write_dynamic`` for fall-through. If we - rebind ``self.write_dynamic = self.proxy_write_dynamic`` when - the subclass DOESN'T override, the delegation infinite-loops - (proxy_write_dynamic → self.write_dynamic → proxy_write_dynamic). - Only rebind when the subclass has its own implementation. - - Net effect: agents whose proxy + BYOA setup is identical (claude, - goose, roo's write_dynamic) inherit the BYOA path under proxy - mode too — no override, no rebind, ``setup.write_dynamic`` stays - as ``write_dynamic``. Agents that genuinely differ (cline + - codex + opencode write_dynamic; cline + roo run_script) get - their proxy_* override bound in. - """ - self.proxy_port = proxy_port - if proxy_port: - cls = type(self) - if cls.proxy_write_dynamic is not AgentSetup.proxy_write_dynamic: - self.write_dynamic = self.proxy_write_dynamic # type: ignore[method-assign] - if cls.proxy_run_script is not AgentSetup.proxy_run_script: - self.run_script = self.proxy_run_script # type: ignore[method-assign] - - #: Env vars the agent's runtime reads for credentials / endpoint - #: routing — i.e. the vars our ``env_overrides`` translates ``llm.*`` - #: config into. Used by ``agent_env`` to surface a transparency - #: warning when the project YAML is empty: we list which of these - #: are actually present in the user's shell so the user can see - #: what the agent will pick up. Empty for IDE-extension agents + #: Env vars the agent's runtime reads for provider credentials / + #: endpoint routing. Used by ``agent_env`` to surface a transparency + #: note about what the agent will pick up from the user's shell: we + #: list which of these are actually present so a missing or wrong + #: value isn't silently consumed. Empty for IDE-extension agents #: that never read env-var credentials. credential_env_vars: ClassVar[tuple[str, ...]] = () - #: Whether this agent makes its LLM calls visible in MLflow natively - #: (without DSAgt's optional ``dsagt-proxy``). Drives whether - #: ``dsagt info`` / live audit / memory extraction see the agent's - #: reasoning + tool calls or just see the agent as a black box. + #: Whether this agent makes its LLM calls visible in MLflow natively. + #: Drives whether ``dsagt info`` / live audit / memory extraction see + #: the agent's reasoning + tool calls or just see the agent as a + #: black box. #: #: ``"full"`` — verified end-to-end (Claude Code, Goose). #: Every agent turn lands in MLflow as a trace #: with messages + response + tool_use blocks. - #: ``--enable-proxy`` is unnecessary. #: ``"partial"`` — agent emits OTel but spans don't carry #: message content (Codex: only token counts + - #: tool names). Conversation may be available - #: via a non-OTel side-channel (Codex's - #: ``~/.codex/sessions/*.jsonl``); not yet - #: wired in. ``--enable-proxy`` recommended. - #: ``"none"`` — agent emits no OTel traces, or emits only - #: metrics without payloads (Cline, Roo Code). - #: The agent is a black box from DSAgt's - #: perspective; ``--enable-proxy`` is the only - #: way to see what it's doing. Tool execution - #: + KB observability still work regardless via + #: tool names). + #: ``"none"`` — agent emits no payload-bearing OTel traces, or + #: emits only metrics without payloads (Cline, + #: Roo Code). The agent is a black box from + #: DSAgt's perspective; tool execution + KB + #: observability still work regardless via #: dsagt-run / MCP-server spans. #: #: See agents/.py docstrings for the per-agent investigation. @@ -552,7 +332,7 @@ def __init__(self, proxy_port: int | None = None): #: (bundled + project) skills here so the agent discovers/auto-invokes them #: without an MCP round-trip. Every supported agent has one — claude #: ``.claude/skills``, codex/goose ``.agents/skills`` (the cross-agent - #: standard), cline ``.cline/skills``, roo ``.roo/skills``. ``None`` would + #: standard), cline ``.cline/skills``. ``None`` would #: mean the agent has no native skill discovery (none currently). native_skills_dir: ClassVar[str | None] = None @@ -573,21 +353,20 @@ def write_dynamic( working_dir: Path, pdir: Path, ) -> list[str]: - """Write the agent's runtime-dependent files (MCP config, .dsagt_env). + """Write the agent's runtime-dependent files (the per-agent MCP config). - Caller must have already populated ``config["mlflow"]["port"]`` with - the actually-bound port and built ``env`` via :func:`agent_env`. - Returns a list of one-line action descriptions. + Caller must have built ``env`` via :func:`agent_env`. Returns a + list of one-line action descriptions. """ def setup_skills(self, working_dir: Path, config: dict) -> list[str]: """Mirror installed (bundled + project) skills into the agent's native skills dir so it auto-discovers/auto-invokes them. - Mode-independent (runs for BYOA and proxy alike) and idempotent — the - manifest-tracked :func:`_mirror_skills_to` only reaps skills dsagt - placed, never user-authored ones. No-op when the agent declares no - ``native_skills_dir`` or ``skills.populate_native`` is disabled. + Idempotent — the manifest-tracked :func:`_mirror_skills_to` only + reaps skills dsagt placed, never user-authored ones. No-op when the + agent declares no ``native_skills_dir`` or ``skills.populate_native`` + is disabled. """ if not self.native_skills_dir: return [] @@ -603,62 +382,36 @@ def setup_skills(self, working_dir: Path, config: dict) -> list[str]: target = target / part return _mirror_skills_to(target, src_dirs) - def runtime_env(self, config: dict) -> dict[str, str]: - """Dsagt-owned env vars the agent process needs at runtime (BYOA). + def owned_artifacts(self, working_dir: Path) -> list[Path]: + """Files/dirs this agent's setup writes, for cleanup when a project + re-inits onto a *different* agent platform. - Returned dict is layered into ``agent_env`` and the launch shim's - telemetry block. Default returns ``telemetry_env`` (claude's - OTEL_LOG_* / CLAUDE_CODE_* gates, etc.). Subclasses augment with - per-project state-dir env (``CLINE_DIR``, ``CODEX_HOME``). + Lists the instruction file, the per-agent MCP-config file(s), and the + agent's private per-project state dir(s) — NOT the shared + ``.agents/`` skill-mirror dir (managed by the manifest reaper), and + never project data (``.dsagt/``, ``kb_index/``, ``trace_archive/``, + ``skills/``). Paths may not all exist; the caller filters. - This is the only method allowed to set agent-affecting env in - BYOA. Provider credentials (ANTHROPIC_*, OPENAI_*, GOOSE_*) are - the user's responsibility — exported in their shell, never - translated from ``config["llm"]``. + Default = just the static marker; subclasses extend. """ - del config - return dict(self.telemetry_env) + return [working_dir / self.static_marker] - def env_overrides(self, config: dict) -> dict[str, str]: - """Phase-2 proxy-mode hook. Not called in BYOA. + def runtime_env(self, config: dict) -> dict[str, str]: + """Dsagt-owned env vars the agent process needs at runtime (BYOA). + + Default is empty: DSAGT no longer forces any telemetry env on the + agent (agent traces are recovered post-hoc from the on-disk + transcript, not by native OTel emission). Subclasses override + only to set per-project state-dir env (``CLINE_DIR``, + ``CODEX_HOME``) that isolates their global config per project. - Reserved for the proxy/OTel-redirect path that ``observability.py`` - will own when Phase 2 lands — the hook each agent uses to translate - ``config["llm"].*`` into provider env vars (after the proxy has - already rewritten the upstream URL). Default implementation - returns ``{}``; agents override with their own translation logic - as needed. + Provider credentials (ANTHROPIC_*, OPENAI_*, GOOSE_*) are the + user's responsibility — exported in their shell, never translated + from ``config["llm"]``. """ del config return {} - def proxy_env_overrides(self, proxy_port: int) -> dict[str, str]: - """Env vars that route the agent's LLM calls through dsagt-proxy. - - Default implementation covers every agent we support today — - we set the well-known base URLs (``ANTHROPIC_BASE_URL``, - ``OPENAI_BASE_URL``) for both wire protocols so an agent that - speaks either still hits the proxy, plus the embedding base - URL so MCP-server children inherit proxy routing for - ``litellm.embedding`` calls. Real upstream credentials live - only in the proxy subprocess; we plant the sentinel API key in - every standard slot so a direct call (bypassing the proxy) - fails loudly with 401 rather than silently leaking around our - observability pipeline. - - Subclasses may override only if an agent reads non-standard - env names. None do today. - """ - proxy_url = f"http://localhost:{proxy_port}" - return { - "ANTHROPIC_BASE_URL": proxy_url, - "OPENAI_BASE_URL": proxy_url, - "EMBEDDING_BASE_URL": proxy_url, - "ANTHROPIC_API_KEY": _PROXY_FORWARDED_SENTINEL, - "OPENAI_API_KEY": _PROXY_FORWARDED_SENTINEL, - "EMBEDDING_API_KEY": _PROXY_FORWARDED_SENTINEL, - } - def interactive_command(self, config: dict) -> list[str]: """Return the argv list for interactive launch. @@ -668,44 +421,21 @@ def interactive_command(self, config: dict) -> list[str]: del config return list(self.base_command) - #: Telemetry env vars this agent emits via its native OTel SDK. - #: Set by subclasses with ``otel_payload_support`` of "full" or - #: "partial". Empty for agents that don't emit OTel (cline / roo). - #: Used by ``byoa_env_hints`` to surface what the user should - #: export in their shell to get full visibility. - telemetry_env: ClassVar[dict[str, str]] = {} - #: Per-agent provider-credential hints surfaced by ``byoa_env_hints``. #: List of ``(env_var_name, hint)`` tuples shown to the user with a #: "skip if already configured" note. credential_hints: ClassVar[tuple[tuple[str, str], ...]] = () - def byoa_env_hints( - self, mlflow_port: int, project: str, project_dir: Path - ) -> list[tuple[str, str]]: + def byoa_env_hints(self) -> list[tuple[str, str]]: """Provider-credential env vars the user should set in their shell. - BYOA design: dsagt-internal env (project routing, MLflow URL, - OTel endpoint + headers, telemetry verbosity flags) lives in - the per-project launch shim — not the user's shell. This - method returns only the credentials the user owns, with one - hint string each. + BYOA design: DSAGT writes no agent-affecting env (no OTel routing, + no telemetry flags). The user owns provider credentials — + exported in their shell — and this returns one hint string each so + ``dsagt init`` can remind them what their agent needs. """ - del mlflow_port, project, project_dir return list(self.credential_hints) - def launch_oneliner(self, project: str, project_dir: Path) -> str: - """Shell command to launch the agent interactively. - - ``cd `` puts both the agent and any ``dsagt-run`` children - in the project directory; readers find ``dsagt_config.yaml`` - there and use it as the single source of truth for project / - agent / mlflow routing — no DSAGT_* env vars to manage. - """ - del project - cmd = " ".join(shlex.quote(part) for part in self.interactive_command({})) - return f"cd {shlex.quote(str(project_dir))} && {cmd}" - @abstractmethod def run_script( self, @@ -725,38 +455,10 @@ def vscode_hint(self, project_dir: Path) -> list[str] | None: """One-or-two-line hint for users who run this agent as a VS Code extension instead of via the CLI. Returns ``None`` for agents without a working VS Code extension (most of them — only claude - and roo currently have extensions that auto-discover dsagt's + currently have extensions that auto-discover dsagt's per-project files from the workspace root). ``dsagt init`` prints these lines under "Or with VS Code extension". """ del project_dir return None - - # --- Phase 2 proxy-mode analogs --------------------------------------- - # ``__init__`` rebinds ``write_dynamic`` / ``run_script`` to these when - # ``proxy_port`` is set, so callers don't branch. Defaults defer to - # the BYOA implementation — agents whose proxy + BYOA paths are the - # same (claude, goose, roo's write_dynamic) inherit this fallthrough. - # Override in subclasses where proxy mode genuinely differs (cline - # auth -b proxy, codex [model_providers.dsagt-proxy] block, opencode - # baseURL override, cline/roo run_script un-punt). - - def proxy_write_dynamic( - self, - config: dict, - env: dict, - working_dir: Path, - pdir: Path, - ) -> list[str]: - return self.write_dynamic(config, env, working_dir, pdir) - - def proxy_run_script( - self, - config: dict, - env: dict, - working_dir: Path, - script_path: Path, - max_turns: int, - ) -> int: - return self.run_script(config, env, working_dir, script_path, max_turns) diff --git a/src/dsagt/agents/claude.py b/src/dsagt/agents/claude.py index 1be23ad..403f0af 100644 --- a/src/dsagt/agents/claude.py +++ b/src/dsagt/agents/claude.py @@ -2,42 +2,20 @@ Claude Code agent setup. Install: ``npm i -g @anthropic-ai/claude-code``. -Generates: ``.mcp.json``, ``CLAUDE.md``, ``.dsagt_env``. - -Post-proxy: the user brings ``ANTHROPIC_API_KEY`` (and optionally -``ANTHROPIC_MODEL``, ``ANTHROPIC_BASE_URL``) themselves and Claude Code -talks directly to its provider. We only inject OTel telemetry env vars -so the agent's LLM-call traces land in the project's MLflow. - -OTel support: **full** for native MLflow visibility (verified). Tool -args land on the ``claude_code.tool_result`` event when -``OTEL_LOG_TOOL_DETAILS=1`` and the assistant's response (with -tool_use blocks) lands on the ``api_response_body`` event when -``OTEL_LOG_RAW_API_BODIES=1``; both are gated. -``CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1`` enables the trace hierarchy. -All four flags are set in ``_CLAUDE_TELEMETRY_ENV`` below. Cited from -https://code.claude.com/docs/en/monitoring-usage.md and verified -end-to-end by the smoke-test harness once ``session.id`` was added to -``OTEL_RESOURCE_ATTRIBUTES``. - -Memory extraction: **does NOT work without --enable-proxy**, even -though traces are visible in the MLflow UI. Claude Code emits its -conversation via OTel *log events* (``api_response_body``, -``tool_result``), which is a different shape from the LiteLLM-autolog -``mlflow.spanInputs`` / ``mlflow.spanOutputs`` shape that -``memory.drain_session_traces`` reads. Users who want both visibility -*and* extraction should run ``dsagt start --enable-proxy``. See -``agents/__init__.py`` module docstring for the design rationale. - -Truncation caveats: ``tool_input`` truncates per-value at 512 chars and -total at ~4 KB; ``api_response_body`` is capped at 60 KB. Large diffs -or huge tool outputs may be clipped. +Generates: ``.mcp.json``, ``CLAUDE.md``, ``.claude/settings.json``. + +The user brings ``ANTHROPIC_API_KEY`` (and optionally ``ANTHROPIC_MODEL``, +``ANTHROPIC_BASE_URL``) themselves and Claude Code talks directly to its +provider. DSAGT sets **no** telemetry env on the agent — agent-side traces +are recovered post-hoc from Claude's on-disk transcript by DSAGT's own +serverless pipeline (MCP-server heartbeat → ``ClaudeReader`` → +``ClaudeTranslator`` → ``MLflowSink``), uniformly with every other agent; not +by forcing native OTel emission or wiring MLflow's autolog hook. Cache-marker injection: Claude Code handles Anthropic prompt caching -natively against the Anthropic API, so the (deleted) proxy's -``_inject_cache_breakpoints`` is unnecessary here. Users on a custom +natively against the Anthropic API. Users on a custom ``ANTHROPIC_BASE_URL`` that proxies to a non-Anthropic provider lose -caching either way. +caching. """ from __future__ import annotations @@ -55,28 +33,6 @@ _run_simple_script, ) -# Env vars Claude Code reads to gate full LLM-call telemetry. Without -# these, the OTel spans only carry counts/cost/duration. See -# https://code.claude.com/docs/en/monitoring-usage.md. -# -# OTEL_LOG_RAW_API_BODIES is intentionally omitted here — its value is -# per-project (a path like ``file:/api_bodies``) and gets rendered -# dynamically by ``_cmd_mlflow``. The ``=1`` (inline) mode would drop -# bodies to ``/v1/logs`` which MLflow's OTLP receiver returns 404 for; -# ``file:

`` writes bodies to disk and stamps a ``body_ref`` on the -# span event, which travels via ``/v1/traces`` (the path MLflow accepts). -# -# OTEL_LOGS_EXPORTER is also dropped — MLflow has no logs endpoint, so -# pointing the SDK at /v1/logs only generates 404s in mlflow.log. -_CLAUDE_TELEMETRY_ENV: dict[str, str] = { - "CLAUDE_CODE_ENABLE_TELEMETRY": "1", - "CLAUDE_CODE_ENHANCED_TELEMETRY_BETA": "1", - "OTEL_LOG_TOOL_DETAILS": "1", - # Without this, user prompts show as "[REDACTED]" in span attributes. - "OTEL_LOG_USER_PROMPTS": "1", - "OTEL_TRACES_EXPORTER": "otlp", -} - class ClaudeSetup(AgentSetup): name = "claude" @@ -84,36 +40,25 @@ class ClaudeSetup(AgentSetup): static_marker = "CLAUDE.md" native_skills_dir = ".claude/skills" install_hint = "Install with `npm i -g @anthropic-ai/claude-code`." - # Anthropic-protocol native; cross-protocol routing requires the proxy. + # Anthropic-protocol native. credential_env_vars = ( "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_MODEL", ) otel_payload_support = "full" - telemetry_env = _CLAUDE_TELEMETRY_ENV credential_hints = ( ("ANTHROPIC_API_KEY", "your Anthropic API key (skip if subscription-authed)"), ("ANTHROPIC_BASE_URL", "optional gateway / proxy URL"), ("ANTHROPIC_MODEL", "optional model override"), ) - def env_overrides(self, config: dict) -> dict[str, str]: - """Phase-2 proxy-mode hook: pin ``ANTHROPIC_MODEL`` to the - upstream-served name so claude doesn't fall back to its - built-in default. Only fires when ``config["proxy"]["port"]`` - is set (gated by ``agents/__init__.py:agent_env``). - - ``ANTHROPIC_BASE_URL`` and ``ANTHROPIC_API_KEY`` are set by - :meth:`proxy_env_overrides` (base default) — point at the - localhost proxy with the sentinel key. Claude posts - ``/v1/messages`` to the proxy regardless of upstream protocol; - the proxy translates. - """ - model = (config.get("llm") or {}).get("model") - if model and not str(model).startswith("${"): - return {"ANTHROPIC_MODEL": model} - return {} + def owned_artifacts(self, working_dir: Path) -> list[Path]: + return [ + working_dir / "CLAUDE.md", + working_dir / ".mcp.json", + working_dir / ".claude", + ] def vscode_hint(self, project_dir: Path) -> list[str]: return [f"Open {project_dir} in VS Code and start the Claude extension."] @@ -138,21 +83,17 @@ def write_dynamic( working_dir: Path, pdir: Path, ) -> list[str]: - """Write ``.mcp.json`` and configure ``mlflow autolog claude``. - - The env block in ``.mcp.json`` carries DSAGT/MLflow/embedding - routing for the MCP-server children — claude inherits parent - env into them, but baking it into the JSON is robust against - shells that don't have those vars set. - - Also wires MLflow's first-class Claude Code integration via - ``.claude/settings.json``: a Stop hook that processes Claude's - transcript at session end and creates a rich MLflow trace with - full prompts, responses, and tool_use blocks. This is the only - way to get high-fidelity agent-side traces in BYOA mode (without - the proxy) — Claude's native OTel emission carries only thin - ``api_response_body`` log events that don't roundtrip through - memory extraction. + """Write ``.mcp.json``. + + The env block carries DSAGT/MLflow/embedding routing for the MCP-server + children — claude inherits parent env into them, but baking it into the + JSON is robust against shells that don't have those vars set. + + No trace wiring here: DSAGT's own serverless pipeline (the MCP-server + heartbeat → ``ClaudeReader`` → ``ClaudeTranslator`` → ``MLflowSink``) + produces Claude's traces, uniformly with every other agent — so we do + NOT also wire MLflow's ``autolog claude`` Stop hook (which would + double-log the same turns, and only Claude can use it serverlessly). """ del env, pdir actions: list[str] = [] @@ -171,25 +112,6 @@ def write_dynamic( # AgentSetup.setup_skills (driven by native_skills_dir) in # dynamic_agent_record — see base.py. Picked up on the next Claude # start, which is fine: this runs at init/start, before launch. - - # Configure mlflow autolog claude — writes .claude/settings.json - # with the MLflow Stop hook + tracking env vars. Idempotent and - # preserves any existing keys in settings.json (mlflow's setup - # functions do a load → update → save, not a replace). - mlflow_port = (config.get("mlflow") or {}).get("port") - project_name = config.get("project") - if mlflow_port and project_name: - from mlflow.claude_code.config import setup_environment_config - from mlflow.claude_code.hooks import setup_hooks_config - - settings_file = working_dir / ".claude" / "settings.json" - setup_hooks_config(settings_file) - setup_environment_config( - settings_file, - tracking_uri=f"http://localhost:{mlflow_port}", - experiment_name=project_name, - ) - actions.append(f"Wrote {settings_file} (mlflow autolog claude)") return actions def run_script( diff --git a/src/dsagt/agents/cline.py b/src/dsagt/agents/cline.py index dd024d4..ae4dcfc 100644 --- a/src/dsagt/agents/cline.py +++ b/src/dsagt/agents/cline.py @@ -6,7 +6,7 @@ ``cline auth`` + ``cline mcp add`` run per-init in :meth:`ClineSetup.write_dynamic` and write to ``$CLINE_DIR/data/``. -**BYOA Phase 1 status: PUNTED for batch / smoke-test.** Cline's bundled +**Batch / smoke-test status: NOT SUPPORTED.** Cline's bundled ``lib.mjs`` ships a hardcoded anthropic-provider model whitelist (``claude-haiku-4-5-20251001``, ``claude-sonnet-4-5-20250929``, etc. — all without the ``-v1-project`` suffix lab gateways like PNNL require). @@ -20,11 +20,8 @@ The openai-native path has the same problem (whitelisted ``gpt-5.5``, ``gpt-4o`` etc.; PNNL serves ``-project``-suffixed variants). Bedrock is locked behind ``cline auth``'s interactive setup flow, not the CLI. - -Phase 2 reactivates batch mode: ``dsagt-proxy`` aliases the cline- -substituted name back to the upstream's served name (same trick old_code -used for roo). Until then, ``cline.run_script`` raises ``RuntimeError`` -and the smoke test short-circuits in ``tests/smoke_test/run.sh``. +So ``cline.run_script`` raises ``RuntimeError`` and the smoke test +short-circuits in ``tests/smoke_test/run.sh``. Interactive use is unaffected — ``dsagt init --agent cline`` still writes the project state, and users running cline via VS Code (where @@ -42,15 +39,6 @@ extraction will see no agent-conversation traces; tool execution and KB observability still work via dsagt-run / MCP-server spans. -Workaround: ``dsagt start --enable-proxy`` makes every cline LLM call -visible in MLflow — each agent turn becomes an inspectable trace -(messages + assistant response + tool_use blocks) you can audit live or -replay. The dsagt-proxy interposes on cline's LLM calls, forwards them -to the user's upstream, and emits an OTel trace on cline's behalf. -Memory extraction is a downstream consequence — but the primary value -of the flag is being able to see what cline is doing at all. Adds one -subprocess per project session. See ``commands/proxy_server.py``. - MCP config: hand-writing ``cline_mcp_settings.json`` is silently ignored; the only path cline loads is via ``cline mcp add``, which writes a stripped schema (no ``env`` block). We patch in the env block ourselves @@ -71,7 +59,6 @@ _DSAGT_MARKER, _load_master_instructions, _mcp_env_block, - _run_simple_script, ) logger = logging.getLogger(__name__) @@ -91,14 +78,12 @@ class ClineSetup(AgentSetup): # path needs a non-standard model env var. Anthropic is the only # path with consistent env conventions: ``ANTHROPIC_BASE_URL`` is read # by cline's anthropic SDK at runtime, covering api.anthropic.com and - # gateway endpoints alike. Match roo's policy. + # gateway endpoints alike. credential_env_vars = ( "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_MODEL", ) - # Cline emits no OTel — agent-side telemetry only via --proxy_traces. - telemetry_env = {} credential_hints = ( ( "ANTHROPIC_API_KEY", @@ -119,6 +104,13 @@ class ClineSetup(AgentSetup): ), ) + def owned_artifacts(self, working_dir: Path) -> list[Path]: + return [ + working_dir / ".clinerules", + working_dir / ".cline-data", + working_dir / ".cline", + ] + def write_static(self, working_dir: Path) -> list[str]: actions: list[str] = [] (working_dir / ".cline-data").mkdir(parents=True, exist_ok=True) @@ -257,157 +249,18 @@ def write_dynamic( actions.append( f"Registered MCP servers and patched {len(mcp_env)} env vars into {mcp_path}" ) - - # ``launch_oneliner`` (used by ``dsagt init`` printout) tells - # the user to run ``cline -v -y -a --config /.cline-data`` - # — the per-project state dir we just populated above. - return actions - - # --- Phase 2 proxy-mode write_dynamic -------------------------------- - # Differs from BYOA only in the auth call: ``cline auth -p openai -b - # http://localhost: -k -m `` instead of - # ``-p anthropic``. The MCP-add + JSON-patch steps are identical, so - # we extract them into a helper. - - def _patch_mcp_servers( - self, - config: dict, - working_dir: Path, - cline_dir: str, - ) -> str: - """Idempotent ``cline mcp add`` + JSON env-block patch. - Returns the action string for the printout. - """ - mcp_path = Path(cline_dir) / "data" / "settings" / "cline_mcp_settings.json" - existing: set[str] = set() - if mcp_path.exists(): - try: - existing = set( - json.loads(mcp_path.read_text()).get("mcpServers", {}).keys() - ) - except (json.JSONDecodeError, OSError): - existing = set() - - if "dsagt" not in existing: - add_cmd = [ - "cline", - "mcp", - "add", - "--config", - cline_dir, - "dsagt", - "--", - "uv", - "run", - "dsagt-server", - ] - result = subprocess.run( - add_cmd, - cwd=str(working_dir), - capture_output=True, - text=True, - ) - if result.returncode != 0: - detail = (result.stderr or result.stdout).strip() - raise RuntimeError( - f"cline mcp add dsagt failed " - f"(exit {result.returncode}): {detail}" - ) - - if not mcp_path.exists(): - raise RuntimeError( - f"cline mcp add succeeded but {mcp_path} was not created — " - "cline may have changed its config layout." - ) - settings = json.loads(mcp_path.read_text()) - mcp_env = _mcp_env_block(config) - for entry in settings.get("mcpServers", {}).values(): - entry["env"] = mcp_env - mcp_path.write_text(json.dumps(settings, indent=2) + "\n") - return ( - f"Registered MCP servers and patched {len(mcp_env)} " - f"env vars into {mcp_path}" - ) - - def proxy_write_dynamic( - self, - config: dict, - env: dict, - working_dir: Path, - pdir: Path, - ) -> list[str]: - """Phase-2 proxy-mode setup. Cline auths against the - localhost dsagt-proxy with the sentinel API key (real upstream - creds live only in the proxy subprocess). Provider is forced - to ``openai`` because cline auth's ``-b/--baseurl`` flag is - openai-only — the proxy speaks /chat/completions on every - upstream regardless of what the agent emits, so this is fine. - """ - del pdir - from .base import _PROXY_FORWARDED_SENTINEL - - actions: list[str] = [] - cline_dir = str(working_dir / ".cline-data") - Path(cline_dir).mkdir(parents=True, exist_ok=True) - - proxy_port = (config.get("proxy") or {}).get("port") - model = (config.get("llm") or {}).get("model") - if not proxy_port or not model: - raise RuntimeError( - "cline proxy-mode write_dynamic requires " - "config['proxy']['port'] and config['llm']['model']." - ) - auth_cmd = [ - "cline", - "auth", - "--config", - cline_dir, - "-p", - "openai", - "-k", - _PROXY_FORWARDED_SENTINEL, - "-m", - model, - "-b", - f"http://localhost:{proxy_port}", - ] - result = subprocess.run( - auth_cmd, - env=env, - cwd=str(working_dir), - capture_output=True, - text=True, - ) - if result.returncode != 0: - detail = (result.stderr or result.stdout).strip() - raise RuntimeError( - f"cline auth (proxy) failed (exit {result.returncode}): {detail}" - ) - actions.append(f"Configured cline auth at {cline_dir} (proxy mode)") - actions.append(self._patch_mcp_servers(config, working_dir, cline_dir)) return actions def runtime_env(self, config: dict) -> dict[str, str]: - """BYOA infrastructure: per-project ``CLINE_DIR`` (state dir) - plus inherited telemetry flags. Isolates per-project MCP / - auth state from the global ``~/.cline-data``. + """BYOA infrastructure: per-project ``CLINE_DIR`` (state dir). + + Isolates per-project MCP / auth state from the global + ``~/.cline-data``. """ env = super().runtime_env(config) env["CLINE_DIR"] = str(Path(config["project_dir"]) / ".cline-data") return env - def launch_oneliner(self, project: str, project_dir: Path) -> str: - """Cline needs ``--config /.cline-data`` so it picks up - the MCP-server registrations + auth state ``write_dynamic`` - wrote. Also ``-v -y -a`` for verbose / auto-yes / act-mode. - """ - del project - import shlex - - pdir = shlex.quote(str(project_dir)) - cline_dir = shlex.quote(str(project_dir / ".cline-data")) - return f"cd {pdir} && cline -v -y -a --config {cline_dir}" - def run_script( self, config: dict, @@ -416,49 +269,18 @@ def run_script( script_path: Path, max_turns: int, ) -> int: - """Punted in BYOA Phase 1 — see module docstring. + """Not supported for cline — see module docstring. Cline's bundled anthropic provider hardcodes a model-ID whitelist that doesn't include lab-gateway aliases (``-v1-project`` etc.), so the request silently substitutes a default cline thinks it - knows and the gateway 401s. Phase 2 ``dsagt-proxy`` fixes this - by aliasing names back to the upstream's served IDs. + knows and the gateway 401s. """ del config, env, working_dir, script_path, max_turns raise RuntimeError( - "cline batch mode is punted in Phase 1 BYOA — cline's anthropic " + "cline batch mode is not supported — cline's anthropic " "provider rewrites unrecognized model names (lab-gateway " "aliases like ``-v1-project``) to its hardcoded default, " - "which the gateway then rejects. Phase 2 reactivates this " - "via dsagt-proxy aliasing. See agents/cline.py module " + "which the gateway then rejects. See agents/cline.py module " "docstring." ) - - def proxy_run_script( - self, - config: dict, - env: dict, - working_dir: Path, - script_path: Path, - max_turns: int, - ) -> int: - """Phase-2 proxy-mode batch run. Un-punts cline: - ``proxy_write_dynamic`` already configured cline auth against - the localhost proxy + registered our MCP servers, so we just - invoke cline pointing at the per-project state dir. The proxy - translates lab-gateway-aliased model names back to upstream- - served names, sidestepping cline's hardcoded whitelist. - """ - del max_turns - text = script_path.read_text().strip() - if not text: - return 1 - cline_dir = env.get("CLINE_DIR") or str( - Path(config["project_dir"]) / ".cline-data" - ) - # ``-a`` (act mode) — without it cline defaults to its task UI - # mode whose model fallback is ``gpt-5.5`` regardless of what - # ``cline auth`` configured for plan/act modes. Model comes - # from the per-project auth state ``proxy_write_dynamic`` populated. - cmd = ["cline", "-v", "-y", "-a", "--config", cline_dir, text] - return _run_simple_script(cmd, env, working_dir, self.install_hint) diff --git a/src/dsagt/agents/codex.py b/src/dsagt/agents/codex.py index 506bc66..96d6306 100644 --- a/src/dsagt/agents/codex.py +++ b/src/dsagt/agents/codex.py @@ -10,12 +10,11 @@ servers). We point ``CODEX_HOME`` at ``/.codex-data`` to keep state isolated per project, and :meth:`CodexSetup.write_dynamic` writes ``[mcp_servers.*]`` blocks with explicit env (codex doesn't -inherit parent env into MCP children, same as cline/roo). +inherit parent env into MCP children, same as cline). -Post-proxy: the user owns model + provider config in -``$CODEX_HOME/config.toml`` (or via ``OPENAI_API_KEY`` / -``OPENAI_BASE_URL`` env). We don't write a model_providers block -anymore. +The user owns model + provider config in ``$CODEX_HOME/config.toml`` +(or via ``OPENAI_API_KEY`` / ``OPENAI_BASE_URL`` env). We don't write +a model_providers block. OTel support: **partial** (verified, ``otel_payload_support = "partial"``). Codex's ``codex-otel`` Rust crate emits OTel spans/logs/metrics, BUT: @@ -64,30 +63,16 @@ def _render_codex_config(mcp_env: dict) -> str: """Render the per-project ``$CODEX_HOME/config.toml`` body. - Emits ``[mcp_servers.*]`` sections plus an ``[otel]`` block. - - No top-level keys are emitted, so the output can be safely appended - to a copy of the user's ``~/.codex/config.toml`` without colliding - on top-level keys like ``model`` or ``approval_policy``. Batch-mode - approval / sandbox are set on the codex CLI directly + Emits only ``[mcp_servers.*]`` sections. No top-level keys are + emitted, so the output can be safely appended to a copy of the user's + ``~/.codex/config.toml`` without colliding on top-level keys like + ``model`` or ``approval_policy``. Batch-mode approval / sandbox are + set on the codex CLI directly (``--dangerously-bypass-approvals-and-sandbox``) instead of here. - The ``[otel]`` block opts codex's partial OTel into MLflow: - - * ``trace_exporter = "otlp-http"`` selects the OTLP-over-HTTP - exporter, which then reads ``OTEL_EXPORTER_OTLP_ENDPOINT`` / - ``OTEL_EXPORTER_OTLP_HEADERS`` from env (set by ``agent_env`` - in batch mode; the user sets them in their shell per the - ``dsagt init`` printout for interactive mode). Without this - key codex's traces never leave the process. - * ``log_user_prompt = true`` flips the ``codex.user_prompt`` log - event from REDACTED to full text, which is what memory - extraction needs to see what the user asked for. - - Codex's LLM-call spans still carry only token counts + tool names - (no request messages, no response bodies) — that's a codex - bundle limitation, not something we can override. Tool *results* - do land in ``codex.tool_result`` log events with full args + output. + No ``[otel]`` block: DSAGT no longer forces codex's native telemetry + (nor the ``log_user_prompt`` privacy override). Codex's conversation + history is recovered post-hoc from its on-disk session rollout. """ lines: list[str] = [] lines.append("[mcp_servers.dsagt]") @@ -100,48 +85,9 @@ def _render_codex_config(mcp_env: dict) -> str: for k, v in mcp_env.items(): lines.append(f"{k} = {_toml_quote(v)}") lines.append("") - lines.extend( - [ - "[otel]", - 'trace_exporter = "otlp-http"', - "log_user_prompt = true", - "", - ] - ) return "\n".join(lines) -def _render_codex_config_proxy(mcp_env: dict, proxy_port: int, model: str) -> str: - """Phase-2 proxy-mode config.toml body. - - Adds, on top of the BYOA MCP-server registrations: - * ``model_provider = "dsagt-proxy"`` (top-level, picks our provider) - * ``[model_providers.dsagt-proxy]`` block with ``base_url`` pointing - at the localhost proxy and ``wire_api = "chat"`` (the proxy - speaks /chat/completions on every upstream). - * Top-level ``model = `` so the user doesn't - need to pass ``-m`` on every ``codex exec`` call. - - These keys go ABOVE the ``[mcp_servers.*]`` sections that - ``_render_codex_config`` produces — top-level keys must precede - any table headers in TOML. - """ - base = _render_codex_config(mcp_env) - header = "\n".join( - [ - f'model = "{model}"', - 'model_provider = "dsagt-proxy"', - "", - "[model_providers.dsagt-proxy]", - 'name = "DSAGT Proxy"', - f'base_url = "http://localhost:{proxy_port}/v1"', - 'wire_api = "chat"', - "", - ] - ) - return header + base - - class CodexSetup(AgentSetup): name = "codex" base_command = ["codex"] @@ -155,14 +101,14 @@ class CodexSetup(AgentSetup): otel_payload_support = "partial" # Codex is openai-protocol native. credential_env_vars = ("OPENAI_API_KEY", "OPENAI_BASE_URL") - # Codex's OTel is config-file driven, not env; spans carry no - # message payload anyway, so we don't surface OTel hints here. - telemetry_env = {} credential_hints = ( ("OPENAI_API_KEY", "your OpenAI API key (skip if subscription-authed)"), ("OPENAI_BASE_URL", "optional gateway / proxy URL"), ) + def owned_artifacts(self, working_dir: Path) -> list[Path]: + return [working_dir / "AGENTS.md", working_dir / ".codex-data"] + def write_static(self, working_dir: Path) -> list[str]: actions: list[str] = [] (working_dir / ".codex-data").mkdir(parents=True, exist_ok=True) @@ -233,74 +179,13 @@ def write_dynamic( actions.append(f"Wrote {config_path} ({len(mcp_env)} MCP env vars)") return actions - def launch_oneliner(self, project: str, project_dir: Path) -> str: - """Codex reads MCP servers from ``$CODEX_HOME/config.toml`` and - has no ``--config`` flag — must inline ``CODEX_HOME`` pointing - at our per-project ``.codex-data/``, otherwise codex reads - ``~/.codex`` and our MCP servers don't register. - """ - del project - import shlex - - pdir = shlex.quote(str(project_dir)) - codex_home = shlex.quote(str(project_dir / ".codex-data")) - return f"cd {pdir} && CODEX_HOME={codex_home} codex" - - def proxy_write_dynamic( - self, - config: dict, - env: dict, - working_dir: Path, - pdir: Path, - ) -> list[str]: - """Phase-2 proxy-mode setup. Same MCP / auth / user-config - copy as BYOA, but renders config.toml with the - ``[model_providers.dsagt-proxy]`` block so codex routes its - LLM calls through our localhost proxy. No subscription auth - needed in proxy mode — the proxy authenticates upstream with - ``LLM_API_KEY``; we plant a sentinel API key in the codex env - so direct calls bypassing the proxy fail loudly. - """ - del env, pdir - import shutil - - actions: list[str] = [] - codex_home = Path(working_dir) / ".codex-data" - codex_home.mkdir(parents=True, exist_ok=True) - user_codex = Path.home() / ".codex" - - # Still copy user's auth.json + config.toml as a base — they may - # have other prefs we want to preserve. Proxy routing layered on - # top via the _proxy renderer. - user_auth = user_codex / "auth.json" - if user_auth.exists(): - dest_auth = codex_home / "auth.json" - shutil.copy2(user_auth, dest_auth) - dest_auth.chmod(0o600) - actions.append(f"Copied {user_auth} → {dest_auth}") - - proxy_port = (config.get("proxy") or {}).get("port") - model = (config.get("llm") or {}).get("model") - if not proxy_port or not model: - raise RuntimeError( - "codex proxy_write_dynamic requires " - "config['proxy']['port'] and config['llm']['model']." - ) - - config_path = codex_home / "config.toml" - user_config = user_codex / "config.toml" - base_toml = user_config.read_text() if user_config.exists() else "" - mcp_env = _mcp_env_block(config) - body = _render_codex_config_proxy(mcp_env, proxy_port, model) - merged = base_toml.rstrip() + "\n\n" + body if base_toml else body - config_path.write_text(merged + "\n") - actions.append(f"Wrote {config_path} ({len(mcp_env)} MCP env vars, proxy mode)") - return actions - def runtime_env(self, config: dict) -> dict[str, str]: - """BYOA infrastructure: per-project ``CODEX_HOME`` (state dir) - plus inherited telemetry flags. Isolates per-project MCP / - config.toml from the global ``~/.codex``. + """BYOA infrastructure: per-project ``CODEX_HOME`` (state dir). + + Isolates per-project MCP / config.toml from the global + ``~/.codex``. Codex has no ``--config`` flag, so the agent must + be launched with this ``CODEX_HOME`` set for our MCP servers to + register. """ env = super().runtime_env(config) env["CODEX_HOME"] = str(Path(config["project_dir"]) / ".codex-data") diff --git a/src/dsagt/agents/goose.py b/src/dsagt/agents/goose.py index bff8a4b..e00a1f2 100644 --- a/src/dsagt/agents/goose.py +++ b/src/dsagt/agents/goose.py @@ -2,33 +2,25 @@ Goose agent setup. Install: see https://github.com/block/goose. -Generates: ``goose.yaml``, ``.goosehints``, ``.dsagt_env``. - -Post-proxy: Goose talks directly to the user's provider configured via -its own ``~/.config/goose/config.yaml`` or ``GOOSE_PROVIDER`` / -``GOOSE_MODEL`` env. We only inject the OTel endpoint env so Goose's -native ``dispatch_tool_call`` span (with full ``{tool, arguments}`` JSON -in ``input``) lands in the project's MLflow. - -OTel support: **full** for native MLflow visibility (verified by -source review of ``crates/goose/src/agents/agent.rs:572-585``). Goose -creates a ``dispatch_tool_call`` span with attribute -``input = {"tool": , "arguments": {...}}`` and ``session.id``, -activating automatically when ``OTEL_EXPORTER_OTLP_ENDPOINT`` is set -(handled by ``agents/__init__.py:agent_env``). No payload-stripping -flag exists — args are exported verbatim. - -Memory extraction: **does NOT work without --enable-proxy**, even -though Goose's traces are visible in the MLflow UI. The -``dispatch_tool_call`` span is in Goose's domain schema, not the -LiteLLM-autolog ``mlflow.spanInputs`` / ``mlflow.spanOutputs`` shape -that ``memory.drain_session_traces`` reads. Users who want both -visibility *and* extraction should run ``dsagt start --enable-proxy``. -See ``agents/__init__.py`` module docstring for the design rationale. - -Caveat: tool *outputs* are NOT in the span (the ``output`` field is -declared but never written). ``dsagt-run``'s ``tool.execute`` spans -cover the execution layer with stdout/stderr/exit-code. +Generates: ``goose.yaml``, ``.goosehints``. + +BYOA: Goose talks directly to the user's provider via its own +``~/.config/goose/config.yaml`` or ``GOOSE_PROVIDER`` / ``GOOSE_MODEL`` env; +DSAGT sets no telemetry env. + +Telemetry / episodic memory: Goose has **no Stop/turn hook and no MLflow +autolog integration**, so DSAGT does not offer the mlflow-autolog or +episodic-memory options for goose — those are gated on agents that have both +(claude / codex / opencode). Goose stays fully supported for the core, +agent-agnostic capabilities (KB retrieval, registered tools, skills, +tool-execution provenance via ``dsagt-run``). If goose gains a hook +mechanism the options can be enabled. + +Gateway note: goose's openai/anthropic providers read ``OPENAI_HOST`` / +``ANTHROPIC_HOST`` for the base URL (a goose-specific naming convention from +its Rust client), NOT the standard ``OPENAI_BASE_URL`` / ``ANTHROPIC_BASE_URL`` +everything else uses. Without HOST set, goose ignores BASE_URL and hits the +provider's default endpoint — silently, for users on a lab gateway. """ from __future__ import annotations @@ -53,15 +45,12 @@ class GooseSetup(AgentSetup): static_marker = ".goosehints" native_skills_dir = ".agents/skills" # cross-agent standard goose discovers install_hint = "See https://github.com/block/goose for installation." + # Goose emits OTel natively, but DSAGT no longer forces/consumes it — and + # goose has no hook, so it gets no autolog / episodic-memory options. otel_payload_support = "full" - # Multi-protocol; goose's runtime reads provider-specific creds plus - # its own GOOSE_PROVIDER / GOOSE_MODEL routing selectors. - # Goose's openai/anthropic providers read ``OPENAI_HOST`` / - # ``ANTHROPIC_HOST`` for the base URL (a goose-specific naming - # convention from its Rust client), NOT the standard - # ``OPENAI_BASE_URL`` / ``ANTHROPIC_BASE_URL`` everything else uses. - # Without HOST set, goose ignores BASE_URL and hits the provider's - # default endpoint — silently for users with a lab gateway. + # Goose's openai/anthropic providers read ``OPENAI_HOST`` / ``ANTHROPIC_HOST`` + # for the base URL (goose-specific naming), plus its own GOOSE_PROVIDER / + # GOOSE_MODEL routing selectors. credential_env_vars = ( "GOOSE_PROVIDER", "GOOSE_MODEL", @@ -73,9 +62,6 @@ class GooseSetup(AgentSetup): "OPENAI_BASE_URL", "OPENAI_HOST", ) - # Goose's Rust client emits OTel automatically when - # OTEL_EXPORTER_OTLP_ENDPOINT is set — no per-platform flags needed. - telemetry_env = {} credential_hints = ( ( "GOOSE_PROVIDER", @@ -83,11 +69,12 @@ class GooseSetup(AgentSetup): ), ("GOOSE_MODEL", "the model name your provider serves"), ("ANTHROPIC_API_KEY", "if GOOSE_PROVIDER=anthropic"), - ("ANTHROPIC_HOST", "if GOOSE_PROVIDER=anthropic and on a gateway / proxy"), + ("ANTHROPIC_HOST", "if GOOSE_PROVIDER=anthropic and on a gateway"), ("OPENAI_API_KEY", "if GOOSE_PROVIDER=openai"), ( "OPENAI_HOST", - "if GOOSE_PROVIDER=openai and on a gateway / proxy (NOT OPENAI_BASE_URL — goose ignores that)", + "if GOOSE_PROVIDER=openai and on a gateway " + "(NOT OPENAI_BASE_URL — goose ignores that)", ), ) @@ -111,9 +98,8 @@ def write_dynamic( working_dir: Path, pdir: Path, ) -> list[str]: - """Write ``goose.yaml``. Goose inherits parent env into MCP - children, so extension entries don't need an explicit env list. - """ + """Write ``goose.yaml``. Goose inherits parent env into MCP children, + so extension entries don't need an explicit env list.""" del config, env, pdir actions: list[str] = [] @@ -137,42 +123,10 @@ def write_dynamic( actions.append(f"Wrote {goose_path}") return actions - def env_overrides(self, config: dict) -> dict[str, str]: - """Phase-2 proxy-mode hook: pin ``GOOSE_PROVIDER`` and - ``GOOSE_MODEL`` so a user's global ``~/.config/goose/config.yaml`` - doesn't override the project's configured model. - - Provider is forced to ``openai`` because the proxy speaks - /chat/completions (openai wire protocol) regardless of upstream; - goose's openai client posts there. Model is the upstream-served - name from ``config["llm"]["model"]``. - """ - model = (config.get("llm") or {}).get("model") - out: dict[str, str] = {"GOOSE_PROVIDER": "openai"} - if model and not str(model).startswith("${"): - out["GOOSE_MODEL"] = model - return out - - def proxy_env_overrides(self, proxy_port: int) -> dict[str, str]: - """Goose-specific proxy routing: add OPENAI_HOST / ANTHROPIC_HOST. - - The base-class default sets ``OPENAI_BASE_URL`` / ``ANTHROPIC_BASE_URL`` - which most agents read; goose ignores those and reads HOST. - Without this override, ``--enable-proxy`` would silently leave - goose talking to the upstream provider directly — same root - cause as the non-proxy path's HOST mapping. - """ - env = super().proxy_env_overrides(proxy_port) - proxy_url = f"http://localhost:{proxy_port}" - env["OPENAI_HOST"] = proxy_url - env["ANTHROPIC_HOST"] = proxy_url - return env - def interactive_command(self, config: dict) -> list[str]: - """Goose only reads ``~/.config/goose/config.yaml`` for extensions, not - a project-local file — so the MCP servers are passed via - ``--with-extension`` flags on the session command to guarantee they - attach for this project. + """Goose reads ``~/.config/goose/config.yaml`` for extensions, not a + project-local file — so the dsagt MCP server is passed via + ``--with-extension`` on the session command to attach for this project. """ del config cmd = list(self.base_command) diff --git a/src/dsagt/agents/opencode.py b/src/dsagt/agents/opencode.py index de764e0..098f820 100644 --- a/src/dsagt/agents/opencode.py +++ b/src/dsagt/agents/opencode.py @@ -9,7 +9,7 @@ OTel support: **none** natively (one third-party plugin exists but isn't wired in by default). Agent transparency in MLflow is limited to MCP-server spans (kb.*, registry.*) and dsagt-run tool.execute spans; LLM-call payloads -require Phase 2 proxy mode. +are not captured. Auth model: opencode reads creds via ``{env:VAR}`` interpolation in its ``opencode.json`` provider block, so we can keep BYOA-pure — the file @@ -23,7 +23,7 @@ Model whitelist: pass-through for known providers (pulled from models.dev) and fully user-controlled for custom providers via ``provider..models``. -Unlike cline/roo, opencode does NOT rewrite gateway-aliased model names. +Unlike cline, opencode does NOT rewrite gateway-aliased model names. Batch mode: ``opencode run --dir --dangerously-skip-permissions -m ``. ``--dir`` is the cwd flag (not ``-C`` / @@ -111,67 +111,6 @@ def _render_opencode_config( return json.dumps(config, indent=2) -def _render_opencode_config_proxy( - mcp_env: dict, - proxy_port: int, - opencode_model: str | None, -) -> str: - """Phase-2 proxy-mode opencode.json body. - - All provider routes hit ``http://localhost:`` regardless - of wire protocol — the proxy normalizes both /v1/messages and - /v1/chat/completions to the upstream's /chat/completions endpoint. - Sentinel API key plants a clear failure mode if a request bypasses - the proxy (the user sees 401, not silent leakage). - - Like the BYOA renderer, we register the user's chosen model under - ``provider..models`` so gateway-aliased names not in models.dev - don't trip ProviderModelNotFoundError. - """ - from .base import _PROXY_FORWARDED_SENTINEL - - proxy_url = f"http://localhost:{proxy_port}" - config: dict = { - "$schema": "https://opencode.ai/config.json", - "mcp": {}, - } - entry: dict = { - "type": "local", - "command": ["uv"] + _mcp_server_args(), - "enabled": True, - } - if mcp_env: - entry["environment"] = dict(mcp_env) - config["mcp"]["dsagt"] = entry - - # Both providers point at the proxy — opencode picks via model prefix. - providers: dict = { - "openai": { - "options": { - "apiKey": _PROXY_FORWARDED_SENTINEL, - "baseURL": proxy_url, - } - }, - "anthropic": { - "options": { - "apiKey": _PROXY_FORWARDED_SENTINEL, - "baseURL": proxy_url, - } - }, - } - - if opencode_model and "/" in opencode_model: - provider_id, model_id = opencode_model.split("/", 1) - if provider_id in providers: - providers[provider_id]["models"] = { - model_id: {"name": model_id}, - } - config["model"] = opencode_model - - config["provider"] = providers - return json.dumps(config, indent=2) - - class OpenCodeSetup(AgentSetup): name = "opencode" base_command = ["opencode"] @@ -189,7 +128,6 @@ class OpenCodeSetup(AgentSetup): "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", ) - telemetry_env = {} credential_hints = ( ( "OPENCODE_MODEL", @@ -207,6 +145,9 @@ class OpenCodeSetup(AgentSetup): ("ANTHROPIC_BASE_URL", "anthropic gateway URL"), ) + def owned_artifacts(self, working_dir: Path) -> list[Path]: + return [working_dir / "AGENTS.md", working_dir / "opencode.json"] + def write_static(self, working_dir: Path) -> list[str]: actions: list[str] = [] instructions = _load_master_instructions() @@ -265,41 +206,6 @@ def write_dynamic( ) return actions - def proxy_write_dynamic( - self, - config: dict, - env: dict, - working_dir: Path, - pdir: Path, - ) -> list[str]: - """Phase-2 proxy-mode setup. All provider routes point at - ``http://localhost:``; the user's gateway creds live - in the proxy subprocess via ``LLM_API_KEY``. Same OPENCODE_MODEL - env var as BYOA — must be ``/``. - """ - del env, pdir - actions: list[str] = [] - proxy_port = (config.get("proxy") or {}).get("port") - opencode_model = (config.get("llm") or {}).get("model") - # opencode_model from config["llm"]["model"] is just a name; the - # user's OPENCODE_MODEL ``/`` form takes precedence - # if set, otherwise default to ``openai/`` since the - # proxy speaks /chat/completions on every upstream. - if opencode_model and "/" not in opencode_model: - opencode_model = f"openai/{opencode_model}" - if not proxy_port or not opencode_model: - raise RuntimeError( - "opencode proxy_write_dynamic requires " - "config['proxy']['port'] and config['llm']['model']." - ) - - mcp_env = _mcp_env_block(config) - body = _render_opencode_config_proxy(mcp_env, proxy_port, opencode_model) - config_path = working_dir / "opencode.json" - config_path.write_text(body + "\n") - actions.append(f"Wrote {config_path} ({len(mcp_env)} MCP env vars, proxy mode)") - return actions - def run_script( self, config: dict, diff --git a/src/dsagt/agents/roo.py b/src/dsagt/agents/roo.py deleted file mode 100644 index bef9816..0000000 --- a/src/dsagt/agents/roo.py +++ /dev/null @@ -1,263 +0,0 @@ -""" -Roo Code agent setup. - -Install: ``npm i -g @roo-code/cli`` (or curl install script — -binary lands at ``~/.local/bin/roo``). - -Generates: ``.roomodes``; per-init, ``.roo/mcp.json`` is written by -:meth:`RooSetup.write_dynamic` with the full env block (roo, like cline, -doesn't inherit parent env into MCP server children). - -**BYOA Phase 1 status: PUNTED for batch / smoke-test.** Roo CLI v0.1.17 -hardcodes endpoints per ``--provider``: - - * ``anthropic`` / ``openai-native`` etc. routes through Roo Code Cloud - (``ROO_CODE_PROVIDER_URL`` defaulting to ``api.roocode.com/proxy``). - With no ``--base-url`` flag, no `*_BASE_URL` env var honored, and - no native-SDK fallback, the CLI cannot be pointed at a self-hosted - gateway. Without cloud auth it falls back to the upstream provider's - default URL (``api.openai.com`` etc.) where the lab-gateway key 401s. - * ``bedrock`` is rejected by the CLI's ``--provider`` validator - despite the ``AwsBedrockHandler`` existing in the bundle and - ``awsBedrockEndpoint`` being a configurable schema field. - * ``vercel-ai-gateway`` has a literally hardcoded URL constant - (``AI_GATEWAY_BASE_URL = "https://ai-gateway.vercel.sh/v1"``); not - even an env var. - * The same model-whitelist substitution issue cline has would also - bite roo's anthropic path — old_code's docstring called it out: - "Roo rewrites our PNNL model name into its own default before - sending — the proxy aliases that name back to the upstream primary." - -Phase 2 reactivates batch mode via ``dsagt-proxy`` at ``localhost:`` -— roo's anthropic SDK happily talks to a localhost endpoint and the -proxy translates names back to upstream-served IDs. - -Interactive use is unaffected — ``dsagt init --agent roo`` still writes -the project state, and users running roo via VS Code (where the UI -exposes ``awsBedrockEndpoint``, ``openAiNativeBaseUrl`` etc.) can drive -the project manually. - -OTel support: **none** (verified, ``otel_payload_support = "none"``). -Roo Code imports zero ``@opentelemetry/*`` packages anywhere in the -agent runtime (``src/``, ``apps/cli/``, ``packages/core/``, -``packages/telemetry/``, ``packages/cloud/``). LLM-call sites in -``src/api/providers/*.ts`` have no span/tracer wrapping. Telemetry is -PostHog-only with payload-free events -(``LLM_COMPLETION``: token counts + cost; ``TASK_CONVERSATION_MESSAGE``: -``taskId`` + ``source``). Standard ``OTEL_EXPORTER_OTLP_ENDPOINT`` is -ignored. Memory extraction will see no agent-conversation traces; -tool execution and KB observability still work via dsagt-run / -MCP-server spans. - -Workaround: ``dsagt start --enable-proxy`` makes every roo LLM call -visible in MLflow — each agent turn becomes an inspectable trace you -can audit live or replay later. Roo's ``--provider anthropic`` honors -``ANTHROPIC_BASE_URL`` env (which ``agent_env`` overrides to the proxy -when the flag is set), so the proxy receives every LLM call and emits -an OTel trace on roo's behalf. Memory extraction is a downstream -consequence; the primary value is real-time transparency on the -agent's actions. See ``commands/proxy_server.py``. - -Batch mode: ``roo --print --oneshot --prompt-file FILE`` reads multi-line -prompts directly — no looping or argv encoding needed. -""" - -from __future__ import annotations - -import json -from pathlib import Path - -from .base import ( - AgentSetup, - _append_or_write, - _build_mcp_servers_dict, - _DSAGT_MARKER, - _format_roomodes, - _load_master_instructions, - _mcp_env_block, - _run_simple_script, -) - - -class RooSetup(AgentSetup): - name = "roo" - base_command = ["roo"] - static_marker = ".roomodes" - native_skills_dir = ".roo/skills" - install_hint = ( - "Install via " - "https://github.com/RooCodeInc/Roo-Code/blob/main/apps/cli/install.sh" - ) - otel_payload_support = "none" - # Roo's CLI multi-provider story is misleading: openai-native has no - # ``--base-url`` flag and the SDK behind it doesn't read - # ``OPENAI_BASE_URL``, so reaching a non-default openai endpoint is - # impossible with that path. Only the anthropic provider works for - # gateways — the Anthropic SDK natively reads ``ANTHROPIC_BASE_URL``. - credential_env_vars = ( - "ANTHROPIC_API_KEY", - "ANTHROPIC_BASE_URL", - "ANTHROPIC_MODEL", - ) - # Roo emits no OTel — agent-side telemetry only via --proxy_traces. - telemetry_env = {} - credential_hints = ( - ( - "ANTHROPIC_API_KEY", - "your provider API key (works for openai-shape " - "gateways too — the Anthropic SDK reaches them via ANTHROPIC_BASE_URL)", - ), - ( - "ANTHROPIC_BASE_URL", - "gateway / proxy URL " - "(roo CLI has no --base-url flag; this env var is the only way)", - ), - ( - "ANTHROPIC_MODEL", - "model name your gateway serves " - "(e.g. claude-haiku-4-5-20251001-v1-project)", - ), - ) - - def vscode_hint(self, project_dir: Path) -> list[str]: - return [ - f"Open {project_dir} in VS Code and start the Roo Code extension.", - "Pick 'DSAgt Pipeline Builder' from Roo's mode dropdown.", - ] - - def write_static(self, working_dir: Path) -> list[str]: - actions: list[str] = [] - (working_dir / ".roo").mkdir(exist_ok=True) - instructions = _load_master_instructions() - if instructions: - # Roo wraps the instructions in a customMode JSON envelope; the - # master marker text survives the wrap because it's part of the - # role definition body. - action = _append_or_write( - working_dir / ".roomodes", - _format_roomodes(instructions), - _DSAGT_MARKER, - ) - if action: - actions.append(action) - return actions - - def env_overrides(self, config: dict) -> dict[str, str]: - """Phase-2 proxy-mode hook: pin ``ANTHROPIC_MODEL`` so roo's - anthropic SDK posts the configured model to the proxy. - - ``proxy_env_overrides`` (base default) sets - ``ANTHROPIC_BASE_URL`` to the proxy URL and ``ANTHROPIC_API_KEY`` - to the sentinel. Roo's hardcoded model whitelist will rewrite - the model name to its current default (``claude-sonnet-4-5``) - before sending; the proxy aliases that rewrite back to the - upstream-served name (see ``_AGENT_PRIMARY_ALIASES`` in - proxy_server.py). - """ - model = (config.get("llm") or {}).get("model") - if model and not str(model).startswith("${"): - return {"ANTHROPIC_MODEL": model} - return {} - - def write_dynamic( - self, - config: dict, - env: dict, - working_dir: Path, - pdir: Path, - ) -> list[str]: - """Write ``.roo/mcp.json``. Roo doesn't inherit parent env into - MCP children — every var the dsagt servers need - (``MLFLOW_TRACKING_URI``, ``DSAGT_PROJECT_DIR``, ``EMBEDDING_*``) - must be explicit in the JSON env block. - """ - del env, pdir - actions: list[str] = [] - mcp_path = working_dir / ".roo" / "mcp.json" - mcp_path.parent.mkdir(parents=True, exist_ok=True) - mcp_env = _mcp_env_block(config) - mcp_config = _build_mcp_servers_dict(mcp_env) - mcp_path.write_text(json.dumps(mcp_config, indent=2) + "\n") - actions.append(f"Wrote {mcp_path} ({len(mcp_env)} env vars)") - return actions - - def launch_oneliner(self, project: str, project_dir: Path) -> str: - """Roo loads customModes from ``.roomodes``; without - ``--mode dsagt`` it runs in the default ``code`` mode and the - DSAgt customInstructions are dropped from the system prompt. - """ - del project - import shlex - - pdir = shlex.quote(str(project_dir)) - return f"cd {pdir} && roo --mode dsagt" - - def run_script( - self, - config: dict, - env: dict, - working_dir: Path, - script_path: Path, - max_turns: int, - ) -> int: - """Punted in BYOA Phase 1 — see module docstring. - - Roo CLI v0.1.17 has no way to reach a self-hosted gateway (no - ``--base-url``, no honored env var, ``bedrock`` rejected at the - provider validator). Phase 2 ``dsagt-proxy`` provides a - localhost endpoint roo's anthropic SDK happily talks to. - """ - del config, env, working_dir, script_path, max_turns - raise RuntimeError( - "roo batch mode is punted in Phase 1 BYOA — roo CLI v0.1.17 " - "has no flag, env var, or non-cloud auth path that points it " - "at a self-hosted gateway. Phase 2 reactivates this via " - "dsagt-proxy. See agents/roo.py module docstring." - ) - - def proxy_run_script( - self, - config: dict, - env: dict, - working_dir: Path, - script_path: Path, - max_turns: int, - ) -> int: - """Phase-2 proxy-mode batch run. Un-punts roo: routes through - ``dsagt-proxy`` at ``ANTHROPIC_BASE_URL`` (set by - ``proxy_env_overrides`` in the base class), so roo's anthropic - SDK posts to localhost. The proxy aliases roo's hardcoded - ``claude-sonnet-4-5`` rewrite back to the upstream's served - name (see ``_AGENT_PRIMARY_ALIASES`` in proxy_server.py). - - Sentinel API key flows through ``--api-key`` so any direct - bypass 401s loudly. Model is the upstream-served name from - ``config["llm"]["model"]`` — roo will rewrite it to its - whitelist default before sending, but the proxy aliases that - rewrite back to the configured upstream. - """ - del max_turns - from .base import _PROXY_FORWARDED_SENTINEL - - model = (config.get("llm") or {}).get("model") - if not model: - raise RuntimeError("roo proxy_run_script requires config['llm']['model'].") - cmd = [ - "roo", - "--print", - "--oneshot", - "--mode", - "dsagt", - "--prompt-file", - str(script_path), - "--workspace", - str(working_dir), - "--debug", - "--provider", - "anthropic", - "--api-key", - _PROXY_FORWARDED_SENTINEL, - "--model", - model, - ] - return _run_simple_script(cmd, env, working_dir, self.install_hint) diff --git a/src/dsagt/commands/cli.py b/src/dsagt/commands/cli.py index adbe375..16b620b 100644 --- a/src/dsagt/commands/cli.py +++ b/src/dsagt/commands/cli.py @@ -1,255 +1,462 @@ """ DSAgt CLI — project initialization and session management. -Two flows: - -1. **BYOA** (default): ``dsagt init --agent `` writes per-agent - MCP config artifacts. ``dsagt mlflow `` backgrounds MLflow - and prints the OTel routing exports the user pastes into the shell - that runs ``claude`` / ``goose``. Native-OTel traces appear in the - MLflow UI but use a shape (``api_response_body`` log events) that - ``dsagt memory`` cannot extract from — for episodic memory, use - proxy mode. -2. **Proxy mode**: ``dsagt start --enable-proxy `` interposes - a LiteLLM proxy between the agent and its provider, autologs every - LLM call into MLflow with ``mlflow.spanInputs`` / - ``mlflow.spanOutputs`` populated. This is the canonical shape that - ``dsagt memory --project X`` reads for episodic-memory extraction - and that the MLflow UI's request/response columns surface natively. +``dsagt init`` is the single, interactive, re-runnable place a user expresses +every choice; the prompts mirror ``.dsagt/config.yaml`` 1:1 and it writes the +per-agent instructions + MCP config. ``dsagt start `` is pure +convenience — ``cd && `` and nothing else (the MCP server +owns the session lifecycle, minting session ids into ``.dsagt/state.yaml`` and +catching up post-session extraction in the background at startup). + +The agent talks to its provider directly — DSAGT never interposes on its +traffic. Self-logging goes to a serverless ``sqlite:////mlflow.db`` +store (no server to run). Usage: - dsagt init --agent [--mlflow-port ] [--location ] - dsagt mlflow - dsagt memory --project - dsagt start [--agent ] [--mlflow-port ] + dsagt init [] # interactive; re-run to reconfigure + dsagt init --agent [--location ] + [--include ... | --exclude ...] # non-interactive + dsagt start dsagt info [--json] - dsagt stop dsagt smoke-test - dsagt setup-kb [--collection ] [--embedding-* flags] dsagt list dsagt mv dsagt rm [-y] [--keep-files] """ import argparse -import json import logging import os -import signal import subprocess import sys import time -from datetime import datetime, timezone from pathlib import Path from dsagt.agents import ( agent_env, dynamic_agent_record, launch_agent, - static_agent_files_present, static_agent_record, AGENTS, ) from dsagt.session import ( + DEFAULT_PROJECTS_BASE, VALID_AGENTS, list_projects, load_config, init_project, - mlflow_command, move_project, - persist_agent_choice, - pick_free_port, project_dir, + read_config_file, + remove_collection, remove_project, - run_extraction, - start_services, - stop_services, ) logger = logging.getLogger(__name__) +# --------------------------------------------------------------------------- +# Interactive prompt helpers +# +# Selection-style prompts (agent, KB collections, skill sources) use +# ``questionary`` for arrow-key navigation + space-to-toggle checkboxes — no +# typing required. Free-text (name / location) and y/N confirms stay plain. +# These run only on the interactive (TTY) path; automation drives init via +# flags and never reaches them. +# --------------------------------------------------------------------------- + + +def _prompt(text: str, default: str | None = None) -> str: + """Free-text prompt; empty input returns *default*.""" + suffix = f" [{default}]" if default not in (None, "") else "" + resp = input(f"{text}{suffix}: ").strip() + return resp or (default or "") + + +def _confirm(text: str, default: bool = False) -> bool: + """Yes/no prompt; empty input returns *default*.""" + hint = "Y/n" if default else "y/N" + resp = input(f"{text} [{hint}] ").strip().lower() + if not resp: + return default + return resp in ("y", "yes") + + +def _select(message: str, options: list[str], default: str) -> str: + """Single-select menu (arrow keys). Aborts init on cancel (Ctrl-C).""" + import questionary + + answer = questionary.select(message, choices=options, default=default).ask() + if answer is None: + raise SystemExit("dsagt init: cancelled.") + return answer + + +def _checkbox(message: str, choices: list[tuple[str, str, bool]]) -> list[str]: + """Multi-select checkbox menu (space toggles, enter confirms). + + *choices* is a list of ``(value, label, checked)``. Returns the selected + values. Aborts init on cancel (Ctrl-C). + """ + import questionary + + qchoices = [ + questionary.Choice(title=label, value=value, checked=checked) + for value, label, checked in choices + ] + # ``instruction=""`` suppresses questionary's own hint line — our message + # already spells out the controls. + answer = questionary.checkbox(message, choices=qchoices, instruction="").ask() + if answer is None: + raise SystemExit("dsagt init: cancelled.") + return answer + + +def _current_assets(pdir: Path) -> list[str]: + """Asset names whose collection already exists in the project's kb_index.""" + from dsagt.commands.setup_core_kb import all_assets, asset_collection_name + + present = [] + for asset in all_assets(): + try: + coll = asset_collection_name(asset) + except ValueError: + continue + if (pdir / "kb_index" / coll).exists(): + present.append(asset) + return present + + +def _skills_block_for(source_names: list[str]) -> dict: + """Build the ``skills`` config block from the selected skill-source names.""" + from dsagt.skills import KNOWN_SOURCES + + sources = [] + for name in source_names: + src = KNOWN_SOURCES[name] + sources.append( + { + "name": name, + "url": src["url"], + "branch": src.get("branch", "main"), + "subdir": src.get("subdir"), + } + ) + return {"sources": sources} + + +def _parse_domain_tags(raw: str) -> dict[str, str]: + """Comma-separated tag names → ``{name: name}`` (self-described closed-set tags). + + Domain tags merge onto the stock taxonomy in :class:`MemoryExtractor`; the + name doubles as the description shown to the judge (no separate prompt for + descriptions — keeps init to one free-text line per the plan §4). + """ + return {t: t for t in (p.strip() for p in raw.split(",")) if t} + + +def _episodic_block(enabled: bool, domain_tags: dict[str, str]) -> dict | None: + """The ``episodic`` config block, or ``None`` when the user didn't opt in. + + Enabling defaults the Tier-1 ``local`` judge (LocalJudge distills facts; the + first heartbeat downloads ~1GB) — the chosen init default. ``None`` keeps a + disabled project's config minimal (``enabled: false`` is backfilled on read). + """ + if not enabled: + return None + return { + "enabled": True, + "judge": {"backend": "local", "model": ""}, + "domain_tags": domain_tags, + "outlier_sensitivity": 0.0, + } + + +def _collect_settings(args, interactive: bool, existing: dict, pdir: Path | None): + """Resolve the init choices (the 1:1 mirror of the config). + + Selection questions: agent platform, packaged KB document *collections*, + skill-catalog *sources*, and the episodic-memory opt-in (+ its domain tags). + The bundled ``tools`` collection is always provisioned and is NOT a + per-project choice. Project name + folder location are resolved by the + caller. Embedding / chunk_size / rerank are code defaults, not init choices. + + Interactive: questionary select/checkbox menus + y/N + free-text, pre-filled + with the project's current choices on re-init. Non-interactive (no TTY): + drive from ``--include`` / ``--exclude`` / ``--episodic`` / ``--domain-tags`` + flags — the automation/test path. + """ + from dsagt.commands.setup_core_kb import COLLECTIONS, resolve_assets + from dsagt.skills import KNOWN_SOURCES + + coll_choices = list(COLLECTIONS) + skill_choices = list(KNOWN_SOURCES) + + if interactive: + agent = _select( + "Agent platform", + list(VALID_AGENTS), + default=existing.get("agent") or args.agent or "claude", + ) + + # Knowledge collections (heavy doc collections; default none). + # Labels are bare names — short enough to never wrap the terminal. + cur_colls = set(existing.get("knowledge", {}).get("collections", [])) + collections = _checkbox( + "Knowledge collections (space toggles, ↑/↓ to move, enter confirms)", + [(c, c, c in cur_colls) for c in coll_choices], + ) + + # Skill-catalog sources (default genesis on a fresh project). + cur_srcs = set( + s["name"] for s in existing.get("skills", {}).get("sources", []) + ) or {"genesis"} + skill_names = _checkbox( + "Skill catalog sources (space toggles, ↑/↓ to move, enter confirms)", + [(s, s, s in cur_srcs) for s in skill_choices], + ) + + # Episodic memory (opt-in): captures session facts into session_memory. + cur_epi = existing.get("episodic", {}) or {} + enable_epi = _confirm( + "Enable episodic memory? (distills session facts with a local LLM; " + "downloads ~1GB on first use)", + default=bool(cur_epi.get("enabled")), + ) + domain_tags: dict[str, str] = {} + if enable_epi: + cur_tags = cur_epi.get("domain_tags") or {} + domain_tags = _parse_domain_tags( + _prompt( + "Domain-specific memory tags, comma-separated (optional)", + default=", ".join(cur_tags), + ) + ) + episodic = _episodic_block(enable_epi, domain_tags) + else: + agent = args.agent or existing.get("agent") + if not agent: + raise SystemExit("dsagt init: --agent is required (non-interactive).") + # --include / --exclude pick the full asset set; split it. + full = resolve_assets(include=args.include, exclude=args.exclude) + collections = [a for a in full if a in COLLECTIONS] + skill_names = [a for a in full if a in KNOWN_SOURCES] + # Episodic is flag-driven here (automation); omit --episodic to leave it + # off. Re-pass it on re-init — like --include/--exclude, flags are + # authoritative on the non-interactive path. + episodic = _episodic_block( + getattr(args, "episodic", False), + _parse_domain_tags(getattr(args, "domain_tags", "") or ""), + ) + + return { + "agent": agent, + # The bundled ``tools`` collection is always provisioned. + "assets": ["tools", *collections, *skill_names], + "knowledge": {"collections": collections}, + "skills": _skills_block_for(skill_names), + "episodic": episodic, + } + + +def _handle_destructive( + existing: dict, settings: dict, pdir: Path, interactive: bool +) -> None: + """Detect destructive deltas on re-init and prompt delete-or-keep. + + Non-interactive (no TTY): never deletes — warns and keeps, so automation + can't lose data and ``input()`` is never called on a closed stdin. + + Never touches agent-populated data: ``tool_use`` / ``session_memory`` + collections, ``.dsagt/`` memory, ``trace_archive/``, ``skills/``. + """ + from dsagt.commands.setup_core_kb import asset_collection_name + + protected = {"tool_use", "session_memory"} + + # Agent switch → old platform's files are now stale. + old_agent = existing.get("agent") + new_agent = settings["agent"] + if old_agent and old_agent != new_agent: + setup = AGENTS[old_agent]() + stale = [p for p in setup.owned_artifacts(pdir) if p.exists()] + if stale: + print(f"\n Switching agent {old_agent} → {new_agent} leaves stale files:") + for p in stale: + print(f" {p}") + if interactive and _confirm(" Delete these stale files?", default=True): + import shutil + + for p in stale: + if p.is_dir(): + shutil.rmtree(p) + else: + p.unlink() + print(" Deleted.") + else: + print(" Kept (remove them manually if you want them gone).") + + # Removed KB collections → their dirs are now orphaned. + new_colls = set() + for a in settings["assets"]: + try: + new_colls.add(asset_collection_name(a)) + except ValueError: + pass + for a in _current_assets(pdir): + try: + coll = asset_collection_name(a) + except ValueError: + continue + if coll in new_colls or coll in protected: + continue + if interactive and _confirm( + f"\n Collection '{coll}' was dropped from the asset set. Remove it?", + default=False, + ): + if remove_collection(pdir, coll): + print(f" Removed kb_index/{coll}.") + else: + print( + f"\n Collection '{coll}' was dropped from the asset set " + "(kept on disk)." + ) + + # Embedding backend/model change invalidates existing embeddings. + old_emb = existing.get("embedding") or {} + new_emb = settings["embedding"] + changed = old_emb.get("backend") != new_emb.get("backend") or old_emb.get( + "model" + ) != new_emb.get("model") + if changed and any((pdir / "kb_index").glob("*/")): + print( + "\n WARNING: embedding backend/model changed. Existing collections " + "were embedded with the old settings and KB search will degrade until " + "they are re-provisioned (delete + re-add the affected assets)." + ) + + def _cmd_init(args): - """Create a new BYOA project. + """Create or reconfigure a BYOA project — interactive and re-runnable. - Writes the agent's static files (instructions, state dirs) and the - runtime artifacts (MCP config: ``.mcp.json`` for claude, ``goose.yaml`` - for goose, etc.) populated with the MLflow port pinned at init time. - Then prints the env-var block + launch one-liner the user needs to - run their own agent. + ``dsagt init`` is the single place a user expresses every choice; the + prompts mirror ``.dsagt/config.yaml`` 1:1. On an existing project it + becomes a settings editor (prompts prefilled with current values) and + prompts before any destructive change (agent switch, removed collection). + Non-interactive (no TTY) drives from flags — the automation/test path. """ - location = Path(args.location).resolve() if args.location else None - pdir, mlflow_port = init_project( - args.project, - args.agent, - mlflow_port=args.mlflow_port, + interactive = sys.stdin.isatty() + + # Project name + name = args.project + if interactive and not name: + name = _prompt("Project name") + if not name: + raise SystemExit("dsagt init: project name required.") + + # Existing project? → re-init (settings editor). + try: + existing_pdir = project_dir(name) + except FileNotFoundError: + existing_pdir = None + existing = read_config_file(existing_pdir) if existing_pdir else {} + reinit = bool(existing) + + # Location (first init only; re-init keeps the registered path). The + # prompt collects the full project directory and defaults to one that + # already ends in the project name. If the user types a path that ends + # in the project name we take it as-is; otherwise we append the name — + # so both "~/proj/myproj" and "~/proj" land at "~/proj/myproj". + if reinit: + location = existing_pdir.parent + pdir_preview = existing_pdir + else: + if interactive: + default_full = str(DEFAULT_PROJECTS_BASE / name) + entered = Path(_prompt("Project location", default=default_full)).resolve() + proj_dir = entered if entered.name == name else entered / name + location = proj_dir.parent + else: + location = Path(args.location).resolve() if args.location else None + pdir_preview = (location or DEFAULT_PROJECTS_BASE) / name + + settings = _collect_settings(args, interactive, existing, pdir_preview) + + if reinit: + _handle_destructive(existing, settings, existing_pdir, interactive) + + include = settings["assets"] if settings["assets"] else None + exclude = ["all"] if not settings["assets"] else None + pdir = init_project( + name, + settings["agent"], location=location, + include=include, + exclude=exclude, + knowledge=settings["knowledge"], + skills=settings["skills"], + episodic=settings["episodic"], ) - print(f"Project created: {pdir}") - print(f" Agent: {args.agent}") - print(f" MLflow port: {mlflow_port}") - print() - config = load_config(args.project) - for action in static_agent_record(config, args.agent, pdir): - print(f" {action}") + agent = settings["agent"] + config = load_config(name) + + # 1. Actions first (Wrote … / Mirrored …). + print() + for action in static_agent_record(config, agent, pdir): + print(action) # Pass the user's shell env so per-agent ``write_dynamic`` can read - # provider creds (e.g., cline.write_dynamic invokes ``cline auth`` - # with ANTHROPIC_API_KEY / ANTHROPIC_MODEL from the shell). + # provider creds (e.g., cline.write_dynamic invokes ``cline auth``). for action in dynamic_agent_record(config, env=dict(os.environ), working_dir=pdir): - print(f" {action}") - - setup = AGENTS[args.agent]() + print(action) + # 2. Project summary. print() - cred_hints = setup.byoa_env_hints(mlflow_port, args.project, pdir) - if cred_hints: - print("Provider credentials (set in your shell; skip any your agent is") - print("already configured to handle for plain chat/coding):") - print() - for var, hint in cred_hints: - print(f" export {var}=... # {hint}") - print() + print(f"Project directory: {pdir}") + print(f"Agent: {agent}") + print(f"Trace store: sqlite:///{pdir}/mlflow.db") - print("Three ways to start:") - print() - print(f" 1. dsagt start {args.project} [--enable-proxy]") - print(" → DSAGT runs everything (MLflow + agent; proxy if requested).") - print() - print(f" 2. cat {pdir}/dsagt-launch.sh") - print(" → view & run the commands manually for full transparency.") + # 3. Startup instructions. print() - print(f" 3. bash {pdir}/dsagt-launch.sh") - print(" → starts MLflow, sets env, then prints how to launch the agent.") - print() - print("Options 2 & 3 are BYOA-only (no proxy).") - if args.agent == "claude": + print(f"Start {agent} in the project directory, or run:") + print(f" dsagt start {name}") + if AGENTS[agent]().vscode_hint(pdir): print() - print("Note: `mlflow autolog claude` was configured automatically.") print( - f" Traces appear at http://localhost:{mlflow_port} after each session." + f"Or open the project directory in VS Code and start the " + f"{agent} extension" ) - print() - print("After your session, extract memory:") - print(f" dsagt memory --project {args.project}") def _cmd_start(args): - """Start a project session: resolve agent → pick ports → start services → - write agent configs → launch. - - Order matters: services start before the agent's runtime configs are - written so the actually-bound ports flow into MCP env blocks. - Static files (instructions) are written here only if missing — when - the user ran ``dsagt init --agent X``, the static record was already - written at init time and we don't touch it. + """Pure convenience: ``cd && ``. + + Nothing more — the agent is launched in the foreground at the project + dir. All configuration is owned by ``dsagt init``; the session + lifecycle (session-id minting, post-session extraction catch-up) is + owned by the MCP server at startup. ``dsagt start`` has no behavior the + user couldn't get by hand with ``cd && ``. + + The per-project runtime env (e.g. ``CLINE_DIR`` / ``CODEX_HOME`` that + point an agent at its init-written config) is still applied so the + launched agent finds what ``init`` set up. """ config = load_config(args.project) pdir = Path(config["project_dir"]) - # Step 1: resolve agent. CLI overrides YAML. If neither is set, - # fail with a one-line message naming both options. - yaml_agent = config.get("agent") - agent = args.agent or yaml_agent - if not agent: - raise ValueError( - "No agent specified. Pass --agent or set 'agent:' in " - f"{pdir / 'dsagt_config.yaml'}." - ) - if agent not in VALID_AGENTS: - raise ValueError(f"agent must be one of {VALID_AGENTS}, got '{agent}'") - config["agent"] = agent - - # Step 2: persist agent into YAML if this is the project's first - # encounter with one. CLI overrides on later runs are per-run only - # — they don't touch the YAML default. - if not yaml_agent: - persist_agent_choice(args.project, agent) - - # Step 3: write the static record on demand — when init didn't run - # with --agent, when the user switched agents, or when a marker - # file was deleted. Idempotent: skips when already present. - if not static_agent_files_present(agent, pdir): - for action in static_agent_record(config, agent, pdir): - print(f" {action}") - - # Step 4: synthesize session id (one per start, threaded through every - # subprocess via DSAGT_SESSION_ID so the MLflow UI can filter to one - # session). - config["session_id"] = ( - f"{config['project']}-" - f"{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}" - ) - - # Step 5: start MLflow (and optionally dsagt-proxy). Picks free ports - # automatically and writes them to /.runtime; CLI overrides - # are honored if present. EVERYTHING from here on must be inside the - # try/finally — otherwise an exception between service start and - # ``launch_agent`` leaks subprocesses we just spawned. - if args.mlflow_port: - config.setdefault("mlflow", {})["port"] = args.mlflow_port - if args.enable_proxy: - # Marker that triggers proxy subprocess in start_services + URL - # injection in agent_env. Port is picked by start_services via - # pick_free_port — same path as MLflow. - config.setdefault("proxy", {}) - ports = start_services(config) - if "proxy" in ports: - print(f" Ports: mlflow={ports['mlflow']}, proxy={ports['proxy']}") - else: - print(f" Ports: mlflow={ports['mlflow']}") - - try: - # Step 7: build env, write dynamic agent configs (with the actual - # MLflow port baked in), launch. - env = agent_env(config) - for action in dynamic_agent_record(config, env, pdir): - print(f" {action}") - - print() - print(f" Project: {config['project']}") - print(f" Agent: {config['agent']}") - print(f" Dir: {pdir}") - print(f" MLflow: http://localhost:{config['mlflow']['port']}") - print() - - return launch_agent( - config, - env, - pdir, - script_path=args.script, - max_turns=args.max_turns, - ) - finally: - print() - # Extraction is best-effort — don't let its failures keep us from - # cleaning up services. - try: - result = run_extraction(args.project) - n_indexed = result.get("tool_use_indexed", 0) - if n_indexed: - print(f" Indexed {n_indexed} tool execution(s) into tool_use") - if result.get("status") == "ok": - print(f" Extracted {result['total_entries']} memories from session") - elif result.get("status") == "empty": - print(" No session exchanges to extract") - # ``tool_use_only`` is the BYOA default (no DSAGT_MEMORY_* - # configured); the indexed-count line above already covered it. - except Exception as e: - print(f" WARNING: extraction failed: {e}") - - # Phase 2 proxy mode: surface any sidechannel-call hits from this - # session so the user can spot a typo in their primary llm.model - # vs. a harmless agent title-gen / session-namer call. No-op - # when the proxy didn't run or no hits were logged. - try: - from dsagt.observability import print_sidechannel_warning - - print_sidechannel_warning(pdir, config.get("session_id")) - except Exception as e: - logger.debug("sidechannel warning failed: %s", e) + print(f" Project: {config['project']}") + print(f" Agent: {config['agent']}") + print(f" Dir: {pdir}") + print() - _stop_one(args.project) + env = agent_env(config) + return launch_agent( + config, + env, + pdir, + script_path=args.script, + max_turns=args.max_turns, + ) def _cmd_list(args): @@ -263,28 +470,19 @@ def _cmd_list(args): for name, path in projects.items(): pdir = Path(path) - config_path = pdir / "dsagt_config.yaml" + cfg_file = pdir / ".dsagt" / "config.yaml" - # Best-effort: if the config is readable, show agent + service status. - # If the project dir is gone or config is broken, just show the path. + # Best-effort: if the config is readable, show the agent. If the + # project dir is gone or the config is broken, just show the path. agent = "" - status = "" - if config_path.exists(): + if cfg_file.exists(): try: config = load_config(name) agent = config.get("agent", "") except (FileNotFoundError, ValueError): pass - runtime_path = pdir / ".runtime" - if runtime_path.exists(): - state = json.loads(runtime_path.read_text()) - ports = state.get("ports", {}) - status = f"running (mlflow:{ports.get('mlflow','?')})" - else: - status = "stopped" - - print(f" {name:<20} {agent:<14} {status:<40} {path}") + print(f" {name:<20} {agent:<14} {path}") def _cmd_mv(args): @@ -297,9 +495,7 @@ def _cmd_mv(args): def _cmd_rm(args): """Unregister a project and (by default) delete its directory. - With ``--all``: bulk-remove every registered project. Reaps active - services per-project (via ``stop_services``) before removing so a - leftover ``.runtime`` doesn't block ``remove_project``. + With ``--all``: bulk-remove every registered project. """ if args.all and args.project: raise SystemExit("dsagt rm: pass either a project name or --all, not both.") @@ -354,12 +550,6 @@ def _cmd_rm_all(args) -> int: failures: list[tuple[str, str]] = [] for name in sorted(projects): - # Reap any active MLflow daemon so remove_project's .runtime - # safety check doesn't block bulk teardown. - try: - stop_services(name) - except Exception as e: - logger.debug("stop_services(%s) raised: %s", name, e) try: remove_project(name, keep_files=args.keep_files) verb_past = "Unregistered" if args.keep_files else "Removed" @@ -375,515 +565,6 @@ def _cmd_rm_all(args) -> int: return 0 -def _cmd_setup_kb(args): - """Build the core knowledge base collections.""" - from dsagt.commands.setup_core_kb import run_setup_kb - - run_setup_kb(args) - - -def _cmd_skills(args): - """Manage external skill catalogs and project skill installs.""" - from dsagt.registry import SkillRegistry - from dsagt.session import kb_from_config, load_config - from dsagt.skills import ( - KNOWN_SOURCES, - SkillRouter, - install_into_project, - persist_source_to_config, - resolve_source, - sync_source, - ) - - def _open_kb(): - """Best-effort KB; None when no embedder is configured (keyword fallback).""" - try: - return kb_from_config(config) - except Exception: - return None - - action = getattr(args, "skills_action", None) - if not action: - print( - "Usage: dsagt skills ...", file=sys.stderr - ) - return 1 - - config = load_config(args.project) - pdir = Path(config["project_dir"]) - - if action == "sync": - kb = kb_from_config(config) - try: - sources = ( - [args.source] - if args.source - else config.get("skills", {}).get("sources", []) - ) - if not sources: - print("No skill sources configured.") - return 0 - for src in sources: - stats = sync_source(src, kb=kb, force=args.force) - print( - f" {stats['url']}: {stats['indexed']} skill(s) indexed (slug {stats['slug']})" - ) - finally: - kb.close() - return 0 - - if action == "add": - from dsagt.skills import SKILL_SOURCES_DIR - - target = args.target - # A "/" target is a source-qualified *install*, not - # a new "owner/repo" source to clone — both have one '/', so distinguish - # by whether the prefix is an already-synced source's cache dir. - qualified_install = ( - target.count("/") == 1 - and (SKILL_SOURCES_DIR / target.split("/", 1)[0]).is_dir() - ) - is_source = not qualified_install and ( - target in KNOWN_SOURCES - or target.startswith(("http://", "https://", "git@")) - or target.count("/") == 1 - ) - if is_source: - spec = resolve_source(target) - if target in KNOWN_SOURCES: - spec.setdefault("name", target) - kb = kb_from_config(config) - try: - stats = sync_source(target, kb=kb) - finally: - kb.close() - persist_source_to_config( - pdir, {"name": spec.get("name", stats["slug"]), **spec} - ) - print(f"Added source {stats['url']}: {stats['indexed']} skill(s) indexed.") - print( - "Run 'dsagt start' to mirror an installed skill natively, or " - f"'dsagt skills add {args.project} ' to install one." - ) - else: - try: - info = install_into_project(target, pdir) - except LookupError as e: - print(f"Error: {e}", file=sys.stderr) - return 1 - print( - f"{info['action'].capitalize()} skill '{info['name']}' at {info['dest_dir']}." - ) - print("It becomes natively discoverable on the next 'dsagt start'.") - return 0 - - if action == "list": - if args.catalog: - kb = _open_kb() - try: - reg = SkillRegistry(runtime_dir=pdir, kb=kb) - sources = SkillRouter(skill_registry=reg, kb=kb).list_sources() - finally: - if kb is not None: - kb.close() - synced = [s for s in sources if s["synced"]] - if synced: - print("Synced catalog sources:") - for s in synced: - print( - f" {s['name']}: {s['indexed']} skill(s) indexed ({s['url']})" - ) - else: - print("No catalog synced. Run 'dsagt skills sync'.") - else: - reg = SkillRegistry(runtime_dir=pdir, kb=None) - skills = reg.list_skills() - print(f"Installed/bundled skills ({len(skills)}):") - for s in skills: - print(f" {s.get('name')} — {(s.get('description') or '')[:80]}") - return 0 - - if action == "search": - kb = _open_kb() - try: - reg = SkillRegistry(runtime_dir=pdir, kb=kb) - router = SkillRouter(skill_registry=reg, kb=kb) - print(router.search(args.query)) - finally: - if kb is not None: - kb.close() - return 0 - - print(f"Unknown skills action: {action}", file=sys.stderr) - return 1 - - -def _cmd_mlflow(args): - """Run MLflow in the foreground. - - Pins the port from the project's internal config so MCP servers - (which bake the URL into their artifacts at init time) agree on - where traces land. Reaps any prior MLflow we left behind on this - project before binding so a stale leftover doesn't block the new - one. If the port is still busy after reap, surfaces the offender - via ``lsof`` so the user knows what to kill. - - With ``--background-only``: idempotent fast-path used by the launch - shim. If MLflow is already running on this project's pinned port, - do nothing and exit 0; otherwise start it and return. - """ - config = load_config(args.project) - pdir = Path(config["project_dir"]) - background_only = getattr(args, "background_only", False) - - port = config.get("mlflow", {}).get("port") - if port is None: - port = pick_free_port() - - # --background-only: short-circuit if MLflow is already up on this port. - # Detect via a TCP probe — the .runtime PID may be stale across machines. - if background_only and _port_responds(port): - return 0 - - # Reap any prior dsagt-spawned MLflow on this project so it doesn't - # hold the pinned port. Same machinery dsagt start uses. - from dsagt.session import reap_runtime - - reaped = reap_runtime(pdir / ".runtime") - for msg in reaped: - print(f" {msg}") - - busy = _port_holder(port) - if busy: - line, pid = busy - print(f" Port {port} held by:") - print(f" {line}") - if pid: - ancestry = _process_ancestry(pid) - if ancestry: - print(f" Process tree: {ancestry}") - if pid and _free_port(port, pid): - print(f" Freed port {port}.") - else: - msg = [ - f"Error: could not free port {port}.", - ] - if pid: - msg.append(f"Try manually: kill -9 {pid}") - else: - msg.append( - "(could not parse PID from lsof output — kill the " - "process shown above by hand)" - ) - msg.append( - "Or re-init with a different port: " - "`dsagt init --mlflow-port `." - ) - print("\n".join(msg), file=sys.stderr) - return 1 - - cmd = mlflow_command(pdir, config.get("mlflow", {}), port=port) - - log_path = pdir / "mlflow.log" - log_fd = open(log_path, "wb") - proc = subprocess.Popen( - cmd, - start_new_session=True, - stdout=log_fd, - stderr=subprocess.STDOUT, - ) - - agent = config["agent"] - session_id = ( - f"{args.project}-" f"{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}" - ) - - runtime_file = pdir / ".runtime" - runtime_file.write_text( - json.dumps( - { - "pids": {"mlflow": proc.pid}, - "ports": {"mlflow": port}, - "session_id": session_id, - "started_at": datetime.now(timezone.utc).isoformat(), - }, - indent=2, - ) - + "\n" - ) - - mlflow_url = f"http://localhost:{port}" - print(" Starting MLflow in background (gunicorn boot ~2-5s)...", flush=True) - experiment_id = _wait_and_resolve_experiment(port, args.project, timeout=20.0) - - if background_only: - # Shim is calling us; it handles the env exports itself. Print one - # confirmation line so the user sees what happened, then return. - print(f" MLflow ready at {mlflow_url} (pid {proc.pid})") - return 0 - - print() - print(f" Project: {args.project}") - print(f" PID: {proc.pid}") - print(f" UI: {mlflow_url}") - if experiment_id is not None: - print(f" Experiment id: {experiment_id}") - else: - print( - f" Experiment id: " - ) - print(f" Session id: {session_id}") - print(f" Logs: {log_path}") - print() - print(" OTel routing for the shell that runs your agent (these go") - print(" straight to the agent's external OTel SDK; project / agent /") - print(f" session_id are read from {pdir}/dsagt_config.yaml") - print(" + .runtime by dsagt's own services, no need to export them):") - print() - # Why http/protobuf + signal-specific TRACES_ENDPOINT: - # - OTel SDKs default to gRPC (port 4317); without this set, claude - # silently tries gRPC and drops every span. - # - Generic OTEL_EXPORTER_OTLP_ENDPOINT auto-appends /v1/traces; we - # use the signal-specific TRACES_ENDPOINT (used as-is) to avoid the - # double-append "...v1/traces/v1/traces" 404. - print(" export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf") - print(f" export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT={mlflow_url}/v1/traces") - if experiment_id is not None: - print( - f" export OTEL_EXPORTER_OTLP_HEADERS=" - f'"x-mlflow-experiment-id={experiment_id}"' - ) - print( - f" export OTEL_RESOURCE_ATTRIBUTES=" - f'"service.name={agent},session.id={session_id}"' - ) - - setup = AGENTS[agent]() - if setup.telemetry_env: - print() - print(f" Agent telemetry verbosity for {agent} — without these, OTel") - print(" spans carry only counts/cost/duration; tool_use payloads") - print(" (which memory extraction needs) are absent:") - print() - for k, v in sorted(setup.telemetry_env.items()): - print(f" export {k}={v}") - if agent == "claude": - # File-mode bodies — writes full request/response JSON to - # /api_bodies/, stamps body_ref on the span event so the - # trace links to the on-disk file. =1 (inline) mode would post - # to /v1/logs which MLflow's OTLP receiver returns 404 for, so - # the bodies vanish. See agents/claude.py for the full reasoning. - print(f" export OTEL_LOG_RAW_API_BODIES=file:{pdir}/api_bodies") - - print() - print(" Note: MLflow always creates a 'Default' experiment (id=0) on") - print(" init. It will stay empty; ignore it. Your traces land in the") - print(f" '{args.project}' experiment.") - print() - print(f" To stop MLflow: dsagt stop {args.project}") - print() - return 0 - - -def _process_ancestry(pid: str) -> str: - """Return ``pid (etime) cmd → ppid (etime) cmd → ...`` walking up to PID 1. - - Helps diagnose orphans: a zombie MLflow worker re-parented to PID 1 - after its dsagt parent died shows up as ``... → 1 systemd``, while a - genuinely-still-running dsagt parent shows up by name. Best-effort: - returns empty string if ps isn't available or the chain breaks. - """ - chain: list[str] = [] - cur = pid - seen: set[str] = set() - while cur and cur not in seen and cur != "0": - seen.add(cur) - try: - result = subprocess.run( - ["ps", "-p", cur, "-o", "pid=,ppid=,etime=,command="], - capture_output=True, - text=True, - check=False, - timeout=2.0, - ) - except (FileNotFoundError, subprocess.TimeoutExpired): - return "" - line = result.stdout.strip() - if not line: - break - parts = line.split(None, 3) - if len(parts) < 4: - break - this_pid, ppid, etime, cmd = parts - chain.append(f"{this_pid} ({etime}) {cmd[:60]}") - if cur == "1": - break - cur = ppid - return " → ".join(chain) - - -def _free_port( - port: int, pid: str, term_timeout: float = 3.0, kill_timeout: float = 1.5 -) -> bool: - """SIGTERM *pid*, wait for *port* to free, escalate to SIGKILL if not. - - Returns True if the port is free at the end. MLflow's gunicorn parent - usually releases the socket on SIGTERM after its workers wind down; - a stuck worker needs SIGKILL. We don't gate on process name — the - contract is "the pinned MLflow port is dsagt's; reclaim it" — but we - log what we kill so the user has a record. - """ - try: - target = int(pid) - except ValueError: - return False - - for sig, timeout in ( - (signal.SIGTERM, term_timeout), - (signal.SIGKILL, kill_timeout), - ): - try: - os.kill(target, sig) - except ProcessLookupError: - pass # already gone — still need to confirm port is free - except PermissionError: - return False - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - if _port_holder(port) is None: - return True - time.sleep(0.2) - return _port_holder(port) is None - - -def _port_responds(port: int) -> bool: - """Return True if something is accepting TCP connections on *port*. - - Used by ``--background-only`` to short-circuit when MLflow is - already up. socket.connect_ex returns 0 on success, errno otherwise. - """ - import socket - - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.settimeout(0.5) - return s.connect_ex(("127.0.0.1", port)) == 0 - - -def _port_holder(port: int) -> tuple[str, str | None] | None: - """Return ``(lsof_line, pid)`` for the process holding *port*, or None. - - Uses ``lsof`` (macOS + Linux). Returns None when the port is free - or when ``lsof`` isn't available (in which case the bind attempt - will surface the failure anyway). ``pid`` is the second whitespace - field of the lsof line; None if the line couldn't be parsed. - """ - try: - # Combine protocol + port into a single -i filter; multiple -i - # arguments are OR'd, which used to make us match any listening - # TCP socket (e.g., rapportd) regardless of the requested port. - result = subprocess.run( - ["lsof", f"-iTCP:{port}", "-sTCP:LISTEN", "-P", "-n"], - capture_output=True, - text=True, - check=False, - timeout=3.0, - ) - except (FileNotFoundError, subprocess.TimeoutExpired): - return None - lines = [l for l in result.stdout.splitlines() if l and not l.startswith("COMMAND")] - if not lines: - return None - line = lines[0] - parts = line.split() - pid = parts[1] if len(parts) >= 2 and parts[1].isdigit() else None - return (line, pid) - - -def _wait_and_resolve_experiment( - port: int, - project: str, - timeout: float, -) -> str | None: - """Poll MLflow until it answers, then look up / create the experiment. - - Returns the numeric experiment id on success, None on timeout or - error (caller still prints the URL — the user can find the id in - the UI). - """ - import socket - import time - - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - try: - with socket.create_connection(("127.0.0.1", port), timeout=0.5): - break - except (ConnectionRefusedError, OSError): - time.sleep(0.25) - else: - return None - try: - import mlflow - - mlflow.set_tracking_uri(f"http://localhost:{port}") - return str(mlflow.set_experiment(project).experiment_id) - except Exception as e: - logger.debug("could not resolve experiment id: %s", e) - return None - - -def _stop_one(project: str) -> int: - """Stop services for a single project via the .runtime state file. - - Returns the number of services killed (0 when nothing was running), - so callers can report "nothing to do" cleanly. Idempotent — safe - to call when no project state file exists. - """ - try: - load_config(project) # validates the project is registered + parsable - except (FileNotFoundError, ValueError) as e: - # Registered project whose config is missing or malformed — skip - # rather than abort a multi-project sweep. - print(f" [{project}] skipping: {e}") - return 0 - - msgs = stop_services(project) - for msg in msgs: - print(f" [{project}] {msg}") - if not msgs: - print(f" [{project}] no running services.") - return len(msgs) - - -def _cmd_stop(args): - """Stop running services. - - Without a project argument, sweeps every registered project so the - common "I just want to clean up whatever's running" case doesn't - require remembering which project name was active. With a project, - behaves as before — single-target. - - Output rules: every action gets a line, prefixed with the project - name; when nothing happened anywhere we still print a final summary. - """ - if args.project: - _stop_one(args.project) - return - - projects = list_projects() - if not projects: - print(" No projects registered.") - return - - total = 0 - for name in sorted(projects): - total += _stop_one(name) - - if total == 0: - print(f" Swept {len(projects)} project(s); nothing to stop.") - - def _cmd_info(args): """Triage summary of a project's MLflow traces.""" from dsagt.commands.info import run @@ -891,59 +572,6 @@ def _cmd_info(args): return run(args.project, as_json=args.json) -def _cmd_memory(args): - """Extract episodic memory from accumulated session traces. - - Tracks a high-water-mark timestamp in - ``/.dsagt/extracted_at.json``. Each invocation extracts - traces newer than the mark and updates it. In BYOA mode (no - ``DSAGT_SESSION_ID`` minted by ``dsagt start``), session boundaries - are fuzzy — we batch all unprocessed traces into one extraction. - """ - config = load_config(args.project) - pdir = Path(config["project_dir"]) - state_path = pdir / ".dsagt" / "extracted_at.json" - state_path.parent.mkdir(parents=True, exist_ok=True) - - last_extracted = None - if state_path.exists(): - last_extracted = json.loads(state_path.read_text()).get("last_extracted_at") - print(f" Last extraction watermark: {last_extracted}") - else: - print(" No prior extraction recorded — processing all available traces.") - - now = datetime.now(timezone.utc).isoformat() - result = run_extraction(args.project) - status = result.get("status", "unknown") - n_indexed = result.get("tool_use_indexed", 0) - if n_indexed: - print(f" Indexed {n_indexed} tool execution(s) into tool_use") - if status == "ok": - print(f" Extracted {result.get('total_entries', 0)} memories") - state_path.write_text( - json.dumps( - { - "last_extracted_at": now, - "previous": last_extracted, - }, - indent=2, - ) - + "\n" - ) - elif status == "empty": - print(" No new traces to extract") - elif status == "tool_use_only": - print( - " LLM-based memory extraction skipped: set " - "DSAGT_MEMORY_API_KEY and DSAGT_MEMORY_MODEL in your shell " - "to enable. Optional: DSAGT_MEMORY_BASE_URL, " - "DSAGT_MEMORY_PROVIDER." - ) - else: - print(f" Extraction returned status={status}: {result}") - return 0 - - def _cmd_smoke_test(args): """Run the end-to-end smoke test (non-interactive, with assertions). @@ -978,7 +606,6 @@ def _run_smoke_all(script: Path) -> int: progress instead of waiting for the slowest agent before any output. """ import tempfile - import time from concurrent.futures import ThreadPoolExecutor, as_completed agents = list(VALID_AGENTS) @@ -1036,22 +663,54 @@ def main(argv=None): sub = parser.add_subparsers(dest="command") - p_init = sub.add_parser("init", help="Create a new project") - p_init.add_argument("project", help="Project name (human-readable alias)") + p_init = sub.add_parser( + "init", + help="Create or reconfigure a project (interactive; re-runnable)", + ) p_init.add_argument( - "--agent", choices=VALID_AGENTS, required=True, help="Agent platform (required)" + "project", + nargs="?", + help="Project name (also the folder name). Prompted if omitted in a TTY.", ) p_init.add_argument( - "--mlflow-port", - type=int, + "--agent", + choices=VALID_AGENTS, default=None, - help="MLflow port to pin (default: pick a free one). Written to " - "the internal config so MCP servers + dsagt mlflow agree on it.", + help="Agent platform. Prompted interactively; required non-interactively.", ) p_init.add_argument( "--location", default=None, - help="Parent directory for the project (default: ~/dsagt-projects/)", + help="Parent directory for the project (default: ~/dsagt-projects/). " + "Non-interactive path; interactive prompts for the full project dir.", + ) + _kb_sel = p_init.add_mutually_exclusive_group() + _kb_sel.add_argument( + "--include", + nargs="+", + metavar="ASSET", + help="KB assets to provision into the project (or 'all' for " + "everything). Default: bundled tools + the genesis skill catalog.", + ) + _kb_sel.add_argument( + "--exclude", + nargs="+", + metavar="ASSET", + help="Provision the default KB set minus these assets ('all' to " + "create the project with no bundled KB content).", + ) + p_init.add_argument( + "--episodic", + action="store_true", + help="Enable episodic memory (Tier-1 local-LLM distillation; downloads " + "~1GB on first use). Off by default.", + ) + p_init.add_argument( + "--domain-tags", + metavar="TAGS", + default="", + help="Comma-separated project-specific memory tags, merged onto the " + "stock taxonomy (only with --episodic).", ) p_start = sub.add_parser("start", help="Start a project session") @@ -1063,33 +722,13 @@ def main(argv=None): help="Agent platform. Required on first start if init didn't set one; " "thereafter, a per-run override (doesn't update the YAML default).", ) - p_start.add_argument( - "--mlflow-port", - type=int, - default=None, - help="Override the MLflow port from dsagt_config.yaml. Useful when " - "the configured port is permanently taken on your machine.", - ) - p_start.add_argument( - "--enable-proxy", - action="store_true", - help="Spawn dsagt-proxy and route the agent's LLM calls through it. " - "For agents that don't natively emit OTel traces with full " - "LLM-call payloads (cline, roo, codex partial), the proxy is " - "what makes their conversations visible in MLflow at all — " - "every agent turn becomes an inspectable trace (real-time " - "audit, replay, debugging) and memory extraction works as a " - "downstream consequence. Agents with " - "otel_payload_support='full' (claude, goose) emit their own " - "traces and don't need the flag. Port is auto-picked.", - ) p_start.add_argument( "--script", default=None, help="Path to a goose-run instructions file. When set, the agent runs " "non-interactively (GOOSE_MODE=auto) against this script — used by " "the smoke test to share the full dsagt start lifecycle (config " - "generation, services, memory extraction, cleanup) with manual runs.", + "generation, memory extraction) with manual runs.", ) p_start.add_argument( "--max-turns", @@ -1098,24 +737,6 @@ def main(argv=None): help="Cap on agent turn count when --script is set (default: 30).", ) - p_mlflow = sub.add_parser( - "mlflow", help="Run MLflow in the foreground against a project's store" - ) - p_mlflow.add_argument("project", help="Project name") - p_mlflow.add_argument( - "--port", - type=int, - default=None, - help="Override the port from dsagt_config.yaml", - ) - p_mlflow.add_argument( - "--background-only", - action="store_true", - help="Start MLflow in background and exit. Skip the OTel-export " - "block (the launch shim handles env setup itself). " - "Idempotent: no-op if MLflow is already running on this project.", - ) - p_info = sub.add_parser( "info", help="Summarize a project's MLflow traces (tokens, errors, by session/source)", @@ -1127,25 +748,6 @@ def main(argv=None): help="Emit the structured report as JSON instead of formatted text", ) - p_memory = sub.add_parser( - "memory", - help="Extract episodic memory from accumulated session traces " - "(BYOA: run after one or more agent sessions to populate the KB)", - ) - p_memory.add_argument("--project", required=True, help="Project name") - - p_stop = sub.add_parser( - "stop", - help="Stop project services (including orphans on configured ports). " - "Without a project argument, sweeps every registered project.", - ) - p_stop.add_argument( - "project", - nargs="?", - default=None, - help="Project name (omit to sweep all registered projects)", - ) - p_smoke = sub.add_parser( "smoke-test", help="Run the end-to-end smoke test (sources DSAGT/.env, drives the agent non-interactively, asserts artifacts)", @@ -1164,47 +766,6 @@ def main(argv=None): "logs go to a temp dir; verdicts print in finish order.", ) - p_setup_kb = sub.add_parser( - "setup-kb", help="Build the core knowledge base collections" - ) - from dsagt.commands.setup_core_kb import add_setup_kb_args - - add_setup_kb_args(p_setup_kb) - - p_skills = sub.add_parser( - "skills", help="Manage external skill catalogs and project installs" - ) - skills_sub = p_skills.add_subparsers(dest="skills_action") - sp_sync = skills_sub.add_parser( - "sync", help="Clone + index skill source(s) into the catalog" - ) - sp_sync.add_argument("project", help="Project name") - sp_sync.add_argument( - "--source", help="Known source name or GitHub URL (default: all configured)" - ) - sp_sync.add_argument( - "--force", action="store_true", help="Re-clone sources from scratch" - ) - sp_add = skills_sub.add_parser( - "add", help="Install a catalog skill, or add+sync a new source" - ) - sp_add.add_argument("project", help="Project name") - sp_add.add_argument( - "target", help="Skill name to install, or source name/URL to add" - ) - sp_list = skills_sub.add_parser( - "list", help="List installed skills (or --catalog collections)" - ) - sp_list.add_argument("project", help="Project name") - sp_list.add_argument( - "--catalog", action="store_true", help="List synced catalog collections" - ) - sp_search = skills_sub.add_parser( - "search", help="Search installed + catalog skills" - ) - sp_search.add_argument("project", help="Project name") - sp_search.add_argument("query", help="Search query") - sub.add_parser("list", help="List all registered projects and their status") p_mv = sub.add_parser("mv", help="Move a project to a new location") @@ -1231,8 +792,12 @@ def main(argv=None): args = parser.parse_args(argv) + # The CLI speaks to the user via ``print()``; library logs are diagnostic. + # Default the console to WARNING so INFO chatter (embedder load, route + # registration, catalog indexing) doesn't bury the init/start output. + # ``--verbose`` opts into the full DEBUG stream. logging.basicConfig( - level=logging.DEBUG if args.verbose else logging.INFO, + level=logging.DEBUG if args.verbose else logging.WARNING, format="%(asctime)s [dsagt] %(message)s", datefmt="%H:%M:%S", ) @@ -1244,13 +809,8 @@ def main(argv=None): cmds = { "init": _cmd_init, "start": _cmd_start, - "mlflow": _cmd_mlflow, - "memory": _cmd_memory, "info": _cmd_info, - "stop": _cmd_stop, "smoke-test": _cmd_smoke_test, - "setup-kb": _cmd_setup_kb, - "skills": _cmd_skills, "list": _cmd_list, "mv": _cmd_mv, "rm": _cmd_rm, diff --git a/src/dsagt/commands/info.py b/src/dsagt/commands/info.py index 0669d0c..ba3721d 100644 --- a/src/dsagt/commands/info.py +++ b/src/dsagt/commands/info.py @@ -1,12 +1,13 @@ """ dsagt info — summary of MLflow traces for triage. -A terse, read-only snapshot of what the project's mlflow.db already knows: -counts and token totals grouped by session and by source (agent turn, -embedding, extraction). Errors surface inline so a user can see "which -session broke" without scrolling. Deep investigation still happens in -the MLflow UI (``dsagt mlflow ``); this command is the triage -layer that tells you *where* to look first. +A terse, read-only snapshot of what the project's serverless +``sqlite:////mlflow.db`` store already knows: counts and token +totals grouped by session and by source (agent turn, embedding, +extraction). Errors surface inline so a user can see "which session +broke" without scrolling. Deep investigation still happens in the MLflow +UI (``mlflow ui --backend-store-uri sqlite:////mlflow.db``); this +command is the triage layer that tells you *where* to look first. Aggregation reads ``trace_metadata`` for token totals + session id (MLflow stamps per-trace token usage as a JSON blob under @@ -16,7 +17,7 @@ Source bucketing comes from the OTel ``service.name`` resource attribute on each trace's root span (set per emitting process by ``init_tracing`` and the agent's own OTel SDK). Possible values: - - ``claude-code`` / ``goose`` / ``cline`` / ``roo`` / ``codex`` — + - ``claude-code`` / ``goose`` / ``cline`` / ``codex`` — agent-emitted LLM-call traces (the bulk of traffic) - ``dsagt-server`` — merged MCP server spans (``kb.*``, ``registry.*``) - ``dsagt-run`` — tool-execute spans @@ -27,7 +28,6 @@ import json import os import re -import sys from pathlib import Path import yaml @@ -38,7 +38,7 @@ _SECRET_LEAF_KEYS = {"api_key"} # Internal/derived sections — irrelevant for "where does this credential # come from" triage and would just clutter the output. -_CONFIG_SOURCE_SKIP_PREFIXES = ("categories.", "extraction.", "knowledge.") +_CONFIG_SOURCE_SKIP_PREFIXES = ("knowledge.", "skills.") def _mask_secret(value: str) -> str: @@ -59,18 +59,18 @@ def _flatten(d: dict, prefix: str = ""): def _config_sources(project_name: str) -> list[dict]: - """Return per-leaf source info for the project's dsagt_config.yaml. + """Return per-leaf source info for the project's .dsagt/config.yaml. Walks the *raw* YAML (not the env-resolved version) so ${VAR} references are visible. For each leaf, reports where the resolved value came from: ``config`` (literal in YAML), ``shell`` (``${VAR}`` resolved against ``os.environ``), or ``unresolved`` (``${VAR}`` with no value anywhere). - No ``.env`` file is read — dsagt-internal env lives in - ``dsagt_config.yaml`` + ``.runtime``; user-provided shell exports are - the only other source. + No ``.env`` file is read — dsagt-internal config lives in + ``.dsagt/config.yaml``; user-provided shell exports are the only other + source. """ pdir = project_dir(project_name) - raw = yaml.safe_load((pdir / "dsagt_config.yaml").read_text()) or {} + raw = yaml.safe_load((pdir / ".dsagt" / "config.yaml").read_text()) or {} rows: list[dict] = [] for path, value in _flatten(raw): @@ -156,8 +156,9 @@ def _tokens(metadata: dict) -> tuple[int, int]: The value is a JSON string, not a dict — MLflow encodes structured metadata as strings so it round-trips through the same storage path - as arbitrary user tags. Missing key → (0, 0); some traces (e.g. the - hardcoded gpt-4o-mini session-namer mock) legitimately have no usage. + as arbitrary user tags. Missing key → (0, 0); some traces (e.g. an + agent's internal title-gen / session-namer call) legitimately have no + usage. """ raw = metadata.get("mlflow.trace.tokenUsage") if not raw: @@ -174,9 +175,9 @@ def _tokens_from_spans(spans) -> tuple[int, int]: Native claude OTel emission stamps these as span attributes on ``claude_code.llm_request`` spans (string-encoded — MLflow's OTLP - receiver JSON-encodes every attribute). ``mlflow.trace.tokenUsage`` - is only set by LiteLLM autolog (proxy mode); for BYOA we aggregate - from the spans themselves so the totals aren't always zero. + receiver JSON-encodes every attribute). When ``mlflow.trace.tokenUsage`` + isn't present on the trace metadata we aggregate from the spans + themselves so the totals aren't always zero. Returns ``(0, 0)`` for non-LLM traces (kb.search, tool.execute) — those don't carry token attributes. @@ -243,19 +244,6 @@ def _fmt_count(n: int) -> str: ("dispatch_tool_call", "goose"), ("complete_with_model", "goose"), ("reply", "goose"), - # dsagt-proxy spans emitted via LiteLLM autolog. The proxy receives - # the agent's request as ``Received Proxy Server Request`` (LiteLLM's - # FastAPI handler span) and forwards via ``litellm_request`` (the - # actual upstream call). Both carry mlflow.spanInputs/Outputs, so - # the UI's request/response columns light up. - ("Received Proxy Server Request", "dsagt-proxy"), - ("litellm_request", "dsagt-proxy"), - # LiteLLM emits ``proxy_pre_call`` spans for pre-call hooks (our - # DSAGTCallback's cache-breakpoint injection runs here). These get - # their own trace_id but with parent_span_id pointing outside the - # trace, so the no-root-span fallback in _source_from_spans needs - # this name registered too. - ("proxy_pre_call", "dsagt-proxy"), ("kb.", "dsagt-server"), ("registry.", "dsagt-server"), ("tool.execute", "dsagt-run"), @@ -275,11 +263,11 @@ def _source_from_spans(spans) -> str | None: """Bucket a trace by who emitted it. Tries ``service.name`` on span attributes / resource first (works for - proxy-mode traces and dsagt-internal services that stamp it - explicitly), then falls back to mapping the root span's NAME prefix - to a source bucket — necessary for native claude / goose OTel because - MLflow's OTLP receiver drops the ``service.name`` resource attribute - in the data ``mlflow.search_traces`` exposes. + dsagt-internal services that stamp it explicitly), then falls back to + mapping the root span's NAME prefix to a source bucket — necessary for + native claude / goose OTel because MLflow's OTLP receiver drops the + ``service.name`` resource attribute in the data ``mlflow.search_traces`` + exposes. ``mlflow.search_traces`` returns a ``spans`` column whose entries vary in shape across MLflow versions (Span object, dict, or pandas @@ -299,8 +287,7 @@ def _source_from_spans(spans) -> str | None: ) if resource: rattrs = getattr(resource, "attributes", None) or ( - resource.get("attributes") - if isinstance(resource, dict) else None + resource.get("attributes") if isinstance(resource, dict) else None ) if rattrs and "service.name" in rattrs: return rattrs["service.name"] @@ -321,9 +308,9 @@ def _source_from_spans(spans) -> str | None: bucket = _bucket_from_span_name(name) if bucket: return bucket - # No root present (rare — LiteLLM's proxy_pre_call spans have - # parent_span_id pointing outside their own trace). Try any - # span whose name we recognize. + # No root present (rare — some emitters produce spans whose + # parent_span_id points outside their own trace). Try any span + # whose name we recognize. for span in spans: name = getattr(span, "name", None) or ( span.get("name") if isinstance(span, dict) else None @@ -362,6 +349,7 @@ def _project_created(pdir: Path) -> str | None: if not ts: return None from datetime import datetime, timezone + return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d") @@ -392,14 +380,48 @@ def _kb_collections(pdir: Path) -> list[dict]: src = None if src: sources[src] = sources.get(src, 0) + 1 - rows.append({ - "collection": sub.name, - "chunks": n_chunks, - "by_source": sources, - }) + rows.append( + { + "collection": sub.name, + "chunks": n_chunks, + "by_source": sources, + } + ) return rows +def _skills(pdir: Path) -> list[dict]: + """Installed + bundled skills for the project. + + Reads the project's ``skills/`` plus the bundled skill dirs via + ``SkillRegistry`` (no embedder needed — this is a directory scan, not a + search). Returns ``[{"name", "description"}, ...]``; empty on any + failure so the report never crashes on a malformed skill. + """ + try: + from dsagt.registry import SkillRegistry + + skills = SkillRegistry(runtime_dir=pdir, kb=None).list_skills() + except Exception: + return [] + return [ + {"name": s.get("name", "?"), "description": s.get("description") or ""} + for s in skills + ] + + +def _print_skills(rows: list[dict]) -> None: + """Render the installed/bundled skill list (name — truncated description).""" + if not rows: + return + name_w = max(len(r["name"]) for r in rows) + print(f"Skills ({len(rows)}):") + for r in rows: + desc = r["description"][:80] + print(f" {r['name']:<{name_w}} {desc}") + print() + + def _kb_retrieval(traces) -> list[dict]: """Per-session ``kb.search`` activity pulled from MLflow trace spans. @@ -423,9 +445,11 @@ def _kb_retrieval(traces) -> list[dict]: ) if name != "kb.search": continue - attrs = getattr(span, "attributes", None) or ( - span.get("attributes") if isinstance(span, dict) else None - ) or {} + attrs = ( + getattr(span, "attributes", None) + or (span.get("attributes") if isinstance(span, dict) else None) + or {} + ) try: hits = int(attrs.get("hits", 0)) except (TypeError, ValueError): @@ -442,8 +466,10 @@ def _kb_retrieval(traces) -> list[dict]: def _load_traces(mlflow_db: Path, project_name: str): """Return (traces_df, experiment_id_or_none). - Separate from the main reporting logic so the caller can decide what to - print when the experiment doesn't exist yet (new project, never run). + Reads the serverless ``sqlite:////mlflow.db`` store directly — no + server required. Separate from the main reporting logic so the caller + can decide what to print when the experiment doesn't exist yet (new + project, never run). """ import mlflow @@ -452,7 +478,8 @@ def _load_traces(mlflow_db: Path, project_name: str): if exp is None: return None, None traces = mlflow.search_traces( - locations=[exp.experiment_id], max_results=5000, + locations=[exp.experiment_id], + max_results=5000, ) return traces, exp.experiment_id @@ -460,7 +487,9 @@ def _load_traces(mlflow_db: Path, project_name: str): def _report(project_name: str, config: dict, traces) -> dict: """Build the structured report dict. CLI formats it; --json prints it.""" agent_header = config.get("agent", "-") - model_header = config.get("llm", {}).get("model", "-") + # BYOA: dsagt no longer records the agent's LLM model (the agent talks to + # its provider directly). Surface the embedding model dsagt configures. + model_header = config.get("embedding", {}).get("model", "-") if traces is None or traces.empty: return { @@ -487,9 +516,9 @@ def _report(project_name: str, config: dict, traces) -> dict: # Source + tokens both walk the spans column. Fall back to span data # because MLflow's OTLP receiver doesn't surface ``service.name`` (so - # metadata-only bucketing returns "unknown" for everything) and only - # LiteLLM-autolog traces carry ``mlflow.trace.tokenUsage`` (so token - # totals are zero in BYOA mode without this fallback). + # metadata-only bucketing returns "unknown" for everything) and not all + # traces carry ``mlflow.trace.tokenUsage`` (so token totals would be + # zero without this fallback). spans_col = traces["spans"] if "spans" in traces.columns else None def _row_source(idx: int) -> str: @@ -498,7 +527,7 @@ def _row_source(idx: int) -> str: return "unknown" def _row_tokens(idx: int) -> tuple[int, int]: - # Trace metadata first (proxy / autolog shape), then aggregate + # Trace metadata first (when tokenUsage is present), then aggregate # span attrs (native claude OTel shape). m = md.iloc[idx] i, o = _tokens(m) @@ -536,7 +565,9 @@ def _group_rows(col: str, sort_by_recency: bool = False) -> list[dict]: "errors": int(g["_err"].sum()), } if col == "_session": - row["agent"] = g["_agent"].mode().iloc[0] if not g["_agent"].empty else "-" + row["agent"] = ( + g["_agent"].mode().iloc[0] if not g["_agent"].empty else "-" + ) row["latest_request_time"] = g["request_time"].max() rows.append(row) if sort_by_recency: @@ -551,12 +582,14 @@ def _group_rows(col: str, sort_by_recency: bool = False) -> list[dict]: # the request column is the display-friendly form. For an error we # just need "which session, which source, when" — the UI has the # payload. - errors.append({ - "session": row["_session"], - "source": row["_source"], - "request_time": row["request_time"], - "trace_id": row["trace_id"], - }) + errors.append( + { + "session": row["_session"], + "source": row["_source"], + "request_time": row["request_time"], + "trace_id": row["trace_id"], + } + ) return { "project": project_name, @@ -575,8 +608,8 @@ def _group_rows(col: str, sort_by_recency: bool = False) -> list[dict]: def _print_text(r: dict) -> None: print(f"Project: {r['project']}") - print(f" Agent: {r['agent']}") - print(f" Model: {r['model']}") + print(f" Agent: {r['agent']}") + print(f" Embedding: {r['model']}") if r.get("created"): print(f" Started: {r['created']}") print() @@ -586,6 +619,7 @@ def _print_text(r: dict) -> None: _print_config_sources(config_sources) _print_kb_collections(r.get("kb_collections") or []) + _print_skills(r.get("skills") or []) if r["total_traces"] == 0: print("No traces recorded yet (run `dsagt start` to create a session).") @@ -596,8 +630,10 @@ def _print_text(r: dict) -> None: f"Totals ({r['total_traces']} traces across {n_sessions} " f"session{'s' if n_sessions != 1 else ''}):" ) - print(f" Tokens: {_fmt_count(r['input_tokens'])} in / " - f"{_fmt_count(r['output_tokens'])} out") + print( + f" Tokens: {_fmt_count(r['input_tokens'])} in / " + f"{_fmt_count(r['output_tokens'])} out" + ) print(f" Errors: {r['total_errors']}") print() @@ -632,14 +668,15 @@ def _print_text(r: dict) -> None: def run(project: str, as_json: bool) -> int: - # Resolve ${ENV_VAR} references so the header shows the actual model - # name the proxy will route (not the placeholder from dsagt_config.yaml). + # Resolve ${ENV_VAR} references so the header shows resolved values + # (not ${VAR} placeholders from .dsagt/config.yaml). config = resolve_env_vars(load_config(project)) pdir = Path(config["project_dir"]) - mlflow_db = pdir / "mlflow" / "mlflow.db" + mlflow_db = pdir / "mlflow.db" sources = _config_sources(project) kb_collections = _kb_collections(pdir) + skills = _skills(pdir) created = _project_created(pdir) if not mlflow_db.exists(): @@ -649,7 +686,7 @@ def run(project: str, as_json: bool) -> int: r = { "project": project, "agent": config.get("agent", "-"), - "model": config.get("llm", {}).get("model", "-"), + "model": config.get("embedding", {}).get("model", "-"), "created": created, "total_traces": 0, "total_errors": 0, @@ -660,6 +697,7 @@ def run(project: str, as_json: bool) -> int: "errors": [], "kb_retrieval": [], "kb_collections": kb_collections, + "skills": skills, "config_sources": sources, } if as_json: @@ -672,6 +710,7 @@ def run(project: str, as_json: bool) -> int: r = _report(project, config, traces) r["created"] = created r["kb_collections"] = kb_collections + r["skills"] = skills r["config_sources"] = sources if as_json: diff --git a/src/dsagt/commands/proxy_server.py b/src/dsagt/commands/proxy_server.py deleted file mode 100644 index 9e9b29c..0000000 --- a/src/dsagt/commands/proxy_server.py +++ /dev/null @@ -1,265 +0,0 @@ -""" -dsagt-proxy: opt-in LiteLLM forwarding proxy for agent transparency. - -DSAgt's normal observability path is the agent emitting OTel directly to -MLflow's ``/v1/traces`` (Claude Code, Goose). For agents that don't -emit OTel with full LLM-call payloads (Cline, Roo Code, Codex -partially), running with ``dsagt start --enable-proxy`` makes the -agent's actions visible at all — every LLM request the agent issues -becomes an MLflow trace you can inspect in real time and replay later: -which model, which messages, which tool_use blocks the assistant emits, -which tool_results came back, full token + cache stats. Without the -proxy, the same agent runs as a black box from DSAgt's perspective — -``dsagt info`` shows only embedding + tool-execute spans, MLflow shows -no agent turns, and end-of-session memory extraction has no -conversation to read. - -Real-time transparency is the primary value. Memory extraction works -as a downstream consequence because the data it needs (request + -response payloads tagged with the session id) lands in MLflow exactly -because the proxy autologged it. - -Architecture: ``init_proxy_tracing()`` installs MLflow's native tracer -provider as the OTel global and plants ``_DSAGTMlflowLogger`` (a -MlflowLogger subclass) into LiteLLM's logger cache. The subclass -stamps ``mlflow.trace.session``, ``dsagt.source=agent``, and -``dsagt.agent`` on every proxy-captured trace in the narrow window -between trace creation and export — giving rich -``mlflow.spanInputs``/``mlflow.spanOutputs`` traces with full request -and response payloads. - -Routing: requests come in on the local port and LiteLLM forwards them -to the user's configured upstream (LLM + embedding) using a minimal -two-route ``model_list`` config. No multi-provider abstraction beyond -what LiteLLM already provides. - -Activation: ``dsagt start --enable-proxy`` sets ``config["proxy"]`` and -``start_services`` spawns this command on a kernel-picked free port. -``agents/__init__.py`` sees the proxy port and overrides the agent's -``ANTHROPIC_BASE_URL`` / ``OPENAI_BASE_URL`` to point at it, plus -plants a sentinel API key so any direct call bypassing the proxy 401s -loudly instead of silently leaking. Without ``--enable-proxy``, this -command is never started — agents talk to their providers directly and -their visibility depends on whether they emit OTel themselves. -""" - -from __future__ import annotations - -import argparse -import logging -import os -import sys -import tempfile - -logger = logging.getLogger(__name__) - - -# Some agents rewrite the requested model name into one of their hardcoded -# "known" Anthropic IDs before sending to /v1/messages — they don't -# recognize lab-gateway-aliased names like -# ``claude-haiku-4-5-20251001-v1-project`` and silently substitute the -# agent's current default. Without aliasing, those primary-reasoning -# calls fall through to the sidechannel wildcard (mock) and the agent -# gets MODEL_NO_ASSISTANT_MESSAGES. -# - roo (v0.1.x): rewrites to ``claude-sonnet-4-5`` -# - cline (1.x): rewrites to ``claude-sonnet-4-5-20250929`` -# Each alias forwards to the configured upstream primary. Grow this list -# when new agents/versions surface their own defaults. -_AGENT_PRIMARY_ALIASES = ( - "claude-sonnet-4-5", - "claude-sonnet-4-5-20250929", -) - -# Agent-specific request fields that some upstreams reject. LiteLLM's -# global ``drop_params: true`` only drops fields it recognizes as -# "supported by some providers, not this one"; unknown fields pass -# through. ``additional_drop_params`` must be set per-model -# (``litellm_params`` level), not globally — verified empirically. -# - ``client_metadata``: Codex sends this; Bedrock Anthropic Messages -# adapter rejects it ("Extra inputs are not permitted"). -_DROP_PARAMS_YAML = ' additional_drop_params: ["client_metadata"]\n' - - -def _generate_config( - llm_model: str, - llm_base_url: str, - llm_provider: str, - embedding_model: str | None = None, - embedding_base_url: str | None = None, - embedding_provider: str | None = None, -) -> str: - """Render the LiteLLM proxy YAML and return the path to a tempfile. - - Layout: - * Primary route forwards the configured llm.model to the upstream. - * One alias route per ``_AGENT_PRIMARY_ALIASES`` entry, also - forwarded to the upstream primary — so cline/roo's hardcoded - model rewrites still reach the right model. - * Embedding route (only when ``embedding_*`` args provided) forwards - the configured embedding model. In ``local`` embedding mode the - knowledge MCP server uses sentence-transformers in-process and - bypasses the proxy entirely, so the embedding route is skipped. - * Sidechannel wildcard catches everything else (agent title-gen, - session-namer, etc.) and returns a canned mock response. - ``observability.SIDECHANNEL_WILDCARD_ROUTE_YAML`` provides this. - """ - from dsagt.observability import SIDECHANNEL_WILDCARD_ROUTE_YAML - - aliases_yaml = "".join(f""" - model_name: {alias} - litellm_params: - model: {llm_provider}/{llm_model} - api_base: {llm_base_url} - api_key: os.environ/LLM_API_KEY -{_DROP_PARAMS_YAML}""" for alias in _AGENT_PRIMARY_ALIASES) - - embedding_yaml = "" - if embedding_model and embedding_base_url and embedding_provider: - embedding_yaml = ( - f" - model_name: {embedding_model}\n" - f" litellm_params:\n" - f" model: {embedding_provider}/{embedding_model}\n" - f" api_base: {embedding_base_url}\n" - f" api_key: os.environ/EMBEDDING_API_KEY\n" - ) - - body = f"""\ -model_list: - - model_name: {llm_model} - litellm_params: - model: {llm_provider}/{llm_model} - api_base: {llm_base_url} - api_key: os.environ/LLM_API_KEY -{_DROP_PARAMS_YAML}{aliases_yaml}{embedding_yaml}{SIDECHANNEL_WILDCARD_ROUTE_YAML}\ -litellm_settings: - drop_params: true - num_retries: 5 - request_timeout: 300 -""" - tmp = tempfile.NamedTemporaryFile( - mode="w", - suffix=".yaml", - prefix="dsagt_litellm_", - delete=False, - ) - tmp.write(body) - tmp.close() - return tmp.name - - -def main() -> None: - parser = argparse.ArgumentParser(prog="dsagt-proxy") - parser.add_argument("--port", type=int, required=True) - parser.add_argument("--host", default="0.0.0.0") - parser.add_argument( - "--mlflow-url", - required=True, - help="MLflow server URL the OTLP exporter ships traces to.", - ) - parser.add_argument( - "--project", - required=True, - help="DSAGT project name (= MLflow experiment name).", - ) - parser.add_argument( - "--session", - required=True, - help="DSAGT session id stamped on every trace via OTel resource attrs.", - ) - parser.add_argument( - "--records-dir", - required=True, - help="Project's trace_archive/ — sidechannel.jsonl lands adjacent.", - ) - parser.add_argument("--model", required=True) - parser.add_argument("--base-url", required=True) - parser.add_argument("--provider", required=True) - # Embedding args optional — only needed when project's embedding - # backend is ``api``. In ``local`` mode the knowledge MCP server - # uses sentence-transformers in-process and never routes through - # the proxy, so we skip the embedding route entirely. - parser.add_argument("--embedding-model", default=None) - parser.add_argument("--embedding-base-url", default=None) - parser.add_argument("--embedding-provider", default=None) - parser.add_argument("--verbose", action="store_true") - args = parser.parse_args() - - logging.basicConfig( - level=logging.DEBUG if args.verbose else logging.INFO, - format="%(asctime)s [dsagt-proxy] %(levelname)s %(message)s", - datefmt="%H:%M:%S", - ) - - if not os.environ.get("LLM_API_KEY"): - logger.error("LLM_API_KEY not set; the proxy needs it to forward LLM requests.") - sys.exit(1) - embedding_routing = bool( - args.embedding_model and args.embedding_base_url and args.embedding_provider - ) - if embedding_routing and not os.environ.get("EMBEDDING_API_KEY"): - logger.error( - "EMBEDDING_API_KEY not set; the proxy needs it to forward embedding requests." - ) - sys.exit(1) - - # init_proxy_tracing installs MLflow's tracer provider as the OTel - # global, plants ``_DSAGTMlflowLogger`` (a MlflowLogger subclass) into - # LiteLLM's logger cache to stamp ``mlflow.trace.session`` / - # ``dsagt.source=agent`` / ``dsagt.agent`` on every proxy-captured - # trace, and registers DSAGTCallback for cache-breakpoint injection + - # sidechannel-call detection. - from dsagt.observability import init_proxy_tracing - - init_proxy_tracing( - mlflow_url=args.mlflow_url, - project=args.project, - session_id=args.session, - records_dir=args.records_dir, - ) - - # Claude Code sends native Anthropic-format requests (POST /v1/messages). - # Default LiteLLM behavior translates those to /responses, which most - # project gateways don't expose. Force the /chat/completions path so - # any openai-compatible upstream works. - import litellm - - litellm.use_chat_completions_url_for_anthropic_messages = True - - # Tell the DSAGT callback what the configured primary model is, so its - # sidechannel detector can distinguish "real upstream call" from - # "wildcard catchall hit". See observability.record_sidechannel_call. - os.environ["DSAGT_PRIMARY_MODEL"] = args.model - - config_path = _generate_config( - args.model, - args.base_url, - args.provider, - args.embedding_model, - args.embedding_base_url, - args.embedding_provider, - ) - logger.info("Generated LiteLLM config at %s", config_path) - logger.info("Starting LiteLLM proxy on %s:%d", args.host, args.port) - - # run_server is a Click command. standalone_mode=False makes Click - # raise on errors instead of sys.exit; we still catch SystemExit - # because Click can raise it on clean shutdown. - from litellm.proxy.proxy_cli import run_server - - try: - run_server.main( - args=[ - "--host", - args.host, - "--port", - str(args.port), - "--config", - config_path, - ], - standalone_mode=False, - ) - except SystemExit: - pass - - -if __name__ == "__main__": - main() diff --git a/src/dsagt/commands/run_tool.py b/src/dsagt/commands/run_tool.py index 4efc16c..233ad78 100644 --- a/src/dsagt/commands/run_tool.py +++ b/src/dsagt/commands/run_tool.py @@ -16,13 +16,24 @@ def _make_parser() -> argparse.ArgumentParser: prog="dsagt-run", description="Wrap a tool command and capture execution provenance.", ) - parser.add_argument("--tool", required=True, help="Name of the tool being executed.") - parser.add_argument("--session", default=None, - help="Session ID. Defaults to session_id from /.runtime.") + parser.add_argument( + "--tool", required=True, help="Name of the tool being executed." + ) + parser.add_argument( + "--session", + default=None, + help="Session ID. Defaults to the DSAGT_SESSION_ID env var.", + ) parser.add_argument("--record-id", default=None, help="Pre-assigned record ID.") - parser.add_argument("--records-dir", default=None, help="Directory for execution records.") - parser.add_argument("--input-files", default=None, help="Comma-separated input file paths.") - parser.add_argument("--output-files", default=None, help="Comma-separated output file paths.") + parser.add_argument( + "--records-dir", default=None, help="Directory for execution records." + ) + parser.add_argument( + "--input-files", default=None, help="Comma-separated input file paths." + ) + parser.add_argument( + "--output-files", default=None, help="Comma-separated output file paths." + ) return parser @@ -37,7 +48,7 @@ def _parse_args(argv: list[str] | None = None) -> tuple[argparse.Namespace, list sys.exit(1) wrapper_args = args_to_parse[:sep] - command_args = args_to_parse[sep + 1:] + command_args = args_to_parse[sep + 1 :] parsed = _make_parser().parse_args(wrapper_args) return parsed, command_args @@ -51,6 +62,7 @@ def main(argv: list[str] | None = None) -> int: return 1 from dsagt.observability import init_tracing + init_tracing("dsagt-run", session_id=args.session) records_dir = _resolve_records_dir(args.records_dir) diff --git a/src/dsagt/commands/setup_core_kb.py b/src/dsagt/commands/setup_core_kb.py index f1f2bb4..b8c433c 100644 --- a/src/dsagt/commands/setup_core_kb.py +++ b/src/dsagt/commands/setup_core_kb.py @@ -1,25 +1,21 @@ """ -dsagt-setup-kb: Build core knowledge base collections. - -Downloads and indexes: -- nemo_curator: NVIDIA NeMo Curator (code, docs, tutorials) -- aidrin: AI Data Readiness Inspector (code, papers) - -The embedding service is configured via CLI flags or environment variables -(``LLM_API_KEY``, ``OPENAI_BASE_URL``, ``EMBEDDING_MODEL``). API-backed -embedding of the full core KB typically takes 15-30 minutes. - -Usage: - dsagt-setup-kb - dsagt-setup-kb --index-dir ./my_kb_index - dsagt-setup-kb --collection nemo_curator - dsagt-setup-kb --embedding-base-url https://api.example.com/v1 \\ - --embedding-api-key sk-... \\ - --embedding-model text-embedding-3-small +Knowledge-base asset builder — the engine behind ``dsagt init``'s KB +provisioning. No longer a standalone command (``dsagt setup-kb`` was +retired); ``session._provision_kb`` calls :func:`resolve_assets` + +:func:`ensure_assets` to build the requested assets into the shared +``~/dsagt-projects/kb_index/`` once, then copies them per project. + +Asset namespace (the ``--include`` / ``--exclude`` selectors on ``dsagt init``): +- ``tools`` bundled tool specs (cheap, local) +- skill catalogs ``genesis`` (default), ``scientific``, ``composio``, … +- scientific collections ``nemo_curator``, ``aidrin`` (heavy; clone external repos) + +:data:`DEFAULT_ASSETS` (bundled tools + the genesis skill catalog) is the +cheap set installed automatically on a machine's first project. Embedding +config comes from the project's ``dsagt_config.yaml`` (local backend by +default — no credentials needed). """ -import argparse -import os import shutil import subprocess import tarfile @@ -217,11 +213,10 @@ def setup_collection( compat with direct callers), a fresh KB is constructed and closed around this call. - ``run_setup_kb`` always passes a shared *kb* so a multi-collection - run pays the model-load cost once, not N times. + ``ensure_assets`` always passes a shared *kb* so a multi-asset build + pays the model-load cost once, not N times. It also prints the + user-facing progress line, so this function stays quiet. """ - print(f"Setting up {name}...", flush=True) - with tempfile.TemporaryDirectory() as tmp: download_dir = Path(tmp) / name download_dir.mkdir() @@ -253,8 +248,9 @@ def setup_collection( kb = KnowledgeBase( index_dir=index_dir, default_embedder=embedding_backend, - default_index=vector_db, - embedder_kwargs=embedder_kwargs, + model=embedder_kwargs.get("model"), + base_url=embedder_kwargs.get("base_url"), + api_key=embedder_kwargs.get("api_key"), ) try: result = kb.ingest( @@ -284,261 +280,244 @@ def _current_dsagt_version() -> str: return "unknown" -def add_setup_kb_args(parser): - """Add setup-kb arguments to a parser or subparser. +# --------------------------------------------------------------------------- +# Installable KB assets — the namespace for ``dsagt init``'s +# ``--include`` / ``--exclude`` selectors. +# +# Three kinds, all built into the shared ``~/dsagt-projects/kb_index/`` and +# copied per-project: +# "tools" bundled tool specs (package data; cheap, fully local) +# a skill-catalog source from ``skills.KNOWN_SOURCES`` +# (e.g. "genesis", "k-dense-ai", "composio", "antigravity") +# a heavy scientific doc collection from ``COLLECTIONS`` +# (e.g. "nemo_curator", "aidrin" — clones external repos) +# +# DEFAULT_ASSETS is the cheap core a first-ever ``dsagt init`` installs +# automatically; everything else is opt-in via ``--include``. +# --------------------------------------------------------------------------- - Called from both the standalone ``dsagt-setup-kb`` entry point and the - ``dsagt setup-kb`` subcommand so the argument set is defined once. - """ - parser.add_argument( - "--index-dir", - type=Path, - default=DEFAULT_INDEX_DIR, - help=f"Index directory (default: {DEFAULT_INDEX_DIR})", - ) - parser.add_argument( - "--collection", - choices=list(COLLECTIONS.keys()), - help="Setup only this collection", - ) - parser.add_argument( - "--embedding-backend", - choices=["api", "local"], - default="local", - help=( - "Embedding backend (default: local — sentence-transformers, " - "no API credentials needed). Pass --embedding-backend api to " - "build collections against a hosted embedding endpoint; that " - "path requires --embedding-base-url and --embedding-api-key (or " - "the corresponding env vars)." - ), - ) - parser.add_argument( - "--embedding-model", - default=None, - help="Embedding model name (falls back to EMBEDDING_MODEL env var)", - ) - parser.add_argument( - "--embedding-base-url", - default=None, - help="Embedding API base URL (falls back to OPENAI_BASE_URL env var)", - ) - parser.add_argument( - "--embedding-api-key", - default=None, - help="Embedding API key (falls back to LLM_API_KEY / OPENAI_API_KEY env var)", - ) - parser.add_argument( - "--vector-db", - choices=["chroma", "faiss"], - default="chroma", - help="Vector database backend (default: chroma)", - ) - parser.add_argument( - "--rebuild", - action="store_true", - help="Re-ingest collections that already exist in the index directory " - "(default: skip existing).", - ) - parser.add_argument( - "--no-skill-catalog", - action="store_true", - help="Skip cloning + indexing the default external skill catalog " - "(the K-Dense scientific skills repo).", - ) +#: The default per-project / first-init asset set: bundled tools + the +#: genesis skill catalog. Kept deliberately cheap (one small local embed + +#: one git clone) so onboarding needs no manual step. +DEFAULT_ASSETS: tuple[str, ...] = ("tools", "genesis") -def run_setup_kb(args): - """Run the core knowledge base setup. +def all_assets() -> list[str]: + """Every installable asset name, in canonical install order (cheap → heavy).""" + from dsagt.skills import KNOWN_SOURCES - Accepts a parsed argparse.Namespace with the fields added by - ``add_setup_kb_args``. Called from both the ``dsagt setup-kb`` - subcommand and the standalone ``dsagt-setup-kb`` entry point. + return ["tools", *KNOWN_SOURCES, *COLLECTIONS] + + +def asset_collection_name(asset: str) -> str: + """The ``kb_index`` collection directory a given asset materializes as.""" + from dsagt.registry import TOOLS_COLLECTION, CATALOG_COLLECTION_PREFIX + from dsagt.skills import KNOWN_SOURCES, _repo_slug + + if asset == "tools": + return TOOLS_COLLECTION + if asset in KNOWN_SOURCES: + return CATALOG_COLLECTION_PREFIX + _repo_slug(KNOWN_SOURCES[asset]["url"]) + if asset in COLLECTIONS: + return asset + raise ValueError(f"unknown KB asset: {asset!r}") + + +def resolve_assets( + include: list[str] | None = None, + exclude: list[str] | None = None, +) -> list[str]: + """Resolve the requested asset set from ``--include`` / ``--exclude``. + + - ``include`` and ``exclude`` are mutually exclusive. + - The literal ``"all"`` expands to every installable asset. + - No selector → :data:`DEFAULT_ASSETS`. + - ``--exclude all`` → ``[]`` (empty stub; the project's KB is created but + holds no bundled content). + + Returns names in canonical install order so a build pays the cheap + (local) assets before the heavy (network) ones. """ - # Show only warnings/errors during setup-kb — the per-collection - # print() lines below are the user-visible progress story. Surfacing - # every "Found N files" / "Created M chunks" log line on top creates - # the noisy play-by-play we deliberately stripped out. - # - # ``force=True`` overrides cli.py's earlier basicConfig (which sets - # INFO + a "[dsagt]" format for the rest of the CLI). Without - # ``force``, the second basicConfig is a no-op because the root - # logger already has handlers, and the INFO-level chatter survives. - import logging as _logging - - _logging.basicConfig( - level=_logging.WARNING, - format="%(levelname)s: %(message)s", - force=True, - ) + if include and exclude: + raise ValueError("--include and --exclude are mutually exclusive") + known = all_assets() - # Check git is available. - try: - subprocess.run(["git", "--version"], capture_output=True, check=True) - except (subprocess.CalledProcessError, FileNotFoundError): - raise RuntimeError("git is required but not found on PATH") - - # Resolve embedding config with env-var fallback so the user gets a - # clear error up front rather than 5 minutes into the first ingest. - embedder_kwargs: dict = {} - if args.embedding_backend == "api": - api_key = ( - args.embedding_api_key - or os.getenv("LLM_API_KEY") - or os.getenv("OPENAI_API_KEY") - ) - base_url = args.embedding_base_url or os.getenv("OPENAI_BASE_URL") - model = args.embedding_model or os.getenv("EMBEDDING_MODEL") - missing = [ - n - for n, v in [("api key", api_key), ("base URL", base_url), ("model", model)] - if not v - ] - if missing: + def _validate(names: list[str]) -> None: + bad = [n for n in names if n != "all" and n not in known] + if bad: raise ValueError( - "API embedding backend requires " - + ", ".join(missing) - + ". Pass via --embedding-* flags or set LLM_API_KEY, " - "OPENAI_BASE_URL, EMBEDDING_MODEL." + f"unknown KB asset(s): {', '.join(bad)}. " + f"Choose from: {', '.join(known)} (or 'all')." ) - embedder_kwargs = {"api_key": api_key, "base_url": base_url, "model": model} - elif args.embedding_model: - embedder_kwargs = {"model": args.embedding_model} - - # Configure LiteLLM retries before any embedding work. setup-kb is a - # one-shot bootstrap tool — no project exists yet, no MLflow to trace - # to, so we skip init_tracing entirely. @traced decorators inside - # KnowledgeBase see no backend and short-circuit cleanly. - from dsagt.observability import configure_litellm_retries - - configure_litellm_retries() - - # One KnowledgeBase per setup-kb invocation. The embedder cache - # lives on the KB instance, so creating fresh KBs per collection - # would reload the local sentence-transformers model every time - # (~11s × N collections of pure waste). Threaded through to - # setup_collection (and used directly for the bundled tools+skills - # block below) so the model loads once. - args.index_dir.mkdir(parents=True, exist_ok=True) - from dsagt.knowledge import KnowledgeBase - from dsagt.registry import ( - TOOLS_COLLECTION, - ToolRegistry, - _parse_frontmatter, - ) - shared_kb = KnowledgeBase( - index_dir=args.index_dir, - default_embedder=args.embedding_backend, - default_index=args.vector_db, - embedder_kwargs=embedder_kwargs or {}, - ) + if include is not None: + _validate(include) + if "all" in include: + return list(known) + sel = set(include) + return [a for a in known if a in sel] + if exclude is not None: + _validate(exclude) + if "all" in exclude: + return [] + excl = set(exclude) + return [a for a in DEFAULT_ASSETS if a not in excl] + return list(DEFAULT_ASSETS) + + +def _model_is_cached(model_id: str) -> bool: + """True if *model_id* is already in the local HuggingFace cache. + + Best-effort: on any error (offline, API change) returns True so we never + print a misleading "downloading" message for an already-present model. + """ try: - # Bundled tools: each spec file is a single chunk with rich - # metadata. Wipe-and-rebuild every run — there's no version - # sentinel, so the user controls when this happens. Bundled - # *skills* are no longer indexed: every supported agent natively - # auto-discovers installed/bundled SKILL.md folders, so skill - # search covers only the external *catalog* tier below. - current_version = _current_dsagt_version() - - tool_paths = [ - p - for p in sorted(ToolRegistry._PACKAGE_TOOLS_DIR.glob("*.md")) - if _parse_frontmatter(p).get("name") - ] - - coll_dir = args.index_dir / TOOLS_COLLECTION - if coll_dir.exists(): - shutil.rmtree(coll_dir) - - if tool_paths: - tool_specs = [_parse_frontmatter(p) for p in tool_paths] - shared_kb.add_entries( - texts=[p.read_text() for p in tool_paths], - collection=TOOLS_COLLECTION, - metadatas=[ - { - "tool_name": s["name"], - "tags": ",".join(s.get("tags", [])), - "executable": s.get("executable", ""), - "has_dependencies": str(bool(s.get("dependencies"))), - "source": "bundled", - "dsagt_version": current_version, - } - for s in tool_specs - ], - ) + from huggingface_hub import try_to_load_from_cache - print(" bundled tools: indexed", flush=True) + # A sentence-transformers model always ships a config.json; a str path + # back means the file is cached (None / sentinel ⇒ not cached). + return isinstance(try_to_load_from_cache(model_id, "config.json"), str) + except Exception: + return True - # External skill catalog: clone + index the default source(s) so - # ``search_skills`` can browse installable skills out of the box. - # Best-effort — a clone failure (offline, repo moved) warns and - # continues rather than aborting the whole KB build. - if not getattr(args, "no_skill_catalog", False): - from dsagt.skills import sync_source - from dsagt.session import DEFAULTS - for src in DEFAULTS["skills"]["sources"]: - try: - stats = sync_source(src, kb=shared_kb, force=args.rebuild) - print( - f" skill catalog {stats['slug']}: {stats['indexed']} indexed", - flush=True, - ) - except Exception as e: # noqa: BLE001 — best-effort, keep going - print( - f" skill catalog {src.get('url', src)}: skipped ({e})", - flush=True, - ) - - collections = ( - {args.collection: COLLECTIONS[args.collection]} - if args.collection - else COLLECTIONS - ) +def _build_bundled_tools(kb, index_dir: Path) -> int: + """(Re)build the bundled-tools collection from the package's tool specs. - for name, config in collections.items(): - target_dir = args.index_dir / name - if _collection_exists(target_dir): - if not args.rebuild: - print( - f" {name}: already indexed (use --rebuild to force)", - flush=True, - ) - continue - shutil.rmtree(target_dir) - setup_collection( - name, - config, - args.index_dir, - embedder_kwargs=embedder_kwargs, - embedding_backend=args.embedding_backend, - vector_db=args.vector_db, - kb=shared_kb, - ) - finally: - shared_kb.close() - - -def main(): - """Standalone entry point (``dsagt-setup-kb``).""" - parser = argparse.ArgumentParser( - description="Setup DSAGT core knowledge base", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=( - "Embedding config is taken from CLI flags, then from these env vars:\n" - " LLM_API_KEY / OPENAI_API_KEY API key for the embedding endpoint\n" - " OPENAI_BASE_URL OpenAI-compatible base URL\n" - " EMBEDDING_MODEL Embedding model name\n\n" - "Full core KB embedding typically takes 15-30 minutes over an API.\n\n" - "This command can also be run as: dsagt setup-kb" - ), + Each spec file is one chunk with rich metadata. Wipe-and-rebuild so a + dsagt upgrade refreshes the bundled set. Returns the number indexed. + """ + from dsagt.registry import TOOLS_COLLECTION, ToolRegistry, _parse_frontmatter + + coll_dir = index_dir / TOOLS_COLLECTION + if coll_dir.exists(): + shutil.rmtree(coll_dir) + + tool_paths = [ + p + for p in sorted(ToolRegistry._PACKAGE_TOOLS_DIR.glob("*.md")) + if _parse_frontmatter(p).get("name") + ] + if not tool_paths: + return 0 + + current_version = _current_dsagt_version() + tool_specs = [_parse_frontmatter(p) for p in tool_paths] + kb.add_entries( + texts=[p.read_text() for p in tool_paths], + collection=TOOLS_COLLECTION, + metadatas=[ + { + "tool_name": s["name"], + "tags": ",".join(s.get("tags", [])), + "executable": s.get("executable", ""), + "has_dependencies": str(bool(s.get("dependencies"))), + "source": "bundled", + "dsagt_version": current_version, + } + for s in tool_specs + ], ) - add_setup_kb_args(parser) - run_setup_kb(parser.parse_args()) + return len(tool_paths) + + +def ensure_assets( + asset_names: list[str], + index_dir: Path, + *, + embedding_backend: str = "local", + embedder_kwargs: dict | None = None, + vector_db: str = "chroma", + rebuild: bool = False, + kb=None, +) -> dict: + """Build the named *asset_names* into *index_dir* (the shared KB cache). + Idempotent: an asset whose collection already exists is skipped unless + *rebuild*, so a second ``dsagt init`` pays nothing. Reuses *kb* when + given (keeps the embedder model warm); otherwise constructs and closes + one. Best-effort per asset for catalogs (a clone failure warns and + continues); a heavy-collection failure propagates. + + Returns ``{"built": [...], "skipped": [...]}``. + """ + from dsagt.skills import KNOWN_SOURCES, sync_source + + index_dir.mkdir(parents=True, exist_ok=True) + + # Decide what actually needs building first, so we stay silent and cheap + # when a later init's requested set is already cached. + to_build = [ + a + for a in asset_names + if rebuild or not _collection_exists(index_dir / asset_collection_name(a)) + ] + skipped = [a for a in asset_names if a not in to_build] + if not to_build: + return {"built": [], "skipped": skipped} + + owned = kb is None + if owned: + from dsagt.knowledge import KnowledgeBase + + ek = embedder_kwargs or {} + kb = KnowledgeBase( + index_dir=index_dir, + default_embedder=embedding_backend, + model=ek.get("model"), + base_url=ek.get("base_url"), + api_key=ek.get("api_key"), + ) + + built: list[str] = [] + try: + # The model loads on the first embed below; announce it so the load + # (or one-time download) isn't a silent pause. Distinguish the two so + # we don't claim a download when the model is already cached. API + # backend has no local model to load. + if embedding_backend == "local": + from dsagt.knowledge import LocalEmbedder + + model_id = (embedder_kwargs or {}).get( + "model" + ) or LocalEmbedder.DEFAULT_MODEL + if _model_is_cached(model_id): + print(" Loading embedding model …", flush=True) + else: + print(" Downloading embedding model (one-time) …", flush=True) + + for asset in to_build: + if asset == "tools": + print(" Indexing bundled tools …", flush=True) + _build_bundled_tools(kb, index_dir) + built.append(asset) + elif asset in KNOWN_SOURCES: + print(f" Fetching skill catalog: {asset} …", flush=True) + try: + sync_source(asset, kb=kb, force=rebuild) + built.append(asset) + except Exception as e: # noqa: BLE001 — best-effort, keep going + print(f" skipped {asset} ({e})", flush=True) + else: # heavy scientific collection + print( + f" Fetching collection: {asset} (may take a few minutes) …", + flush=True, + ) + coll = index_dir / asset_collection_name(asset) + if coll.exists(): + shutil.rmtree(coll) + setup_collection( + asset, + COLLECTIONS[asset], + index_dir, + embedder_kwargs=embedder_kwargs or {}, + embedding_backend=embedding_backend, + vector_db=vector_db, + kb=kb, + ) + built.append(asset) + finally: + if owned: + kb.close() -if __name__ == "__main__": - main() + return {"built": built, "skipped": skipped} diff --git a/src/dsagt/judge.py b/src/dsagt/judge.py new file mode 100644 index 0000000..4660769 --- /dev/null +++ b/src/dsagt/judge.py @@ -0,0 +1,310 @@ +""" +The episodic-memory Judge — distills one conversational turn into a few tagged, +≤1-sentence facts for the ``session_memory`` collection. + +This is Phase-3's Tier-1 layer (see ``design-notes/memory-plan.md``). A +:class:`Judge` is the small generative LLM that turns a turn's exchanges into +``[{text, tag}]`` facts: closed-set tag classification over the project's tag +taxonomy + a ≤1-sentence *extractive* condense, with an explicit empty escape +(most turns produce nothing). Reliability comes from asking it to do little — +classify into a fixed set and copy out one sentence, never "invent a category" +or summarize — which is exactly what a small (1–3B-class) model does well, and a +GBNF grammar pins the output to the JSON-array-of-tagged-facts shape so even a +1.5B model cannot emit malformed JSON or an off-taxonomy tag. + +The abstraction mirrors :class:`dsagt.knowledge.Embedder`: ``Judge.create`` +selects a backend; :class:`LocalJudge` is the default (an embeddable GGUF via +``llama-cpp-python`` — *local-by-default* so no second API key/cost gates +adoption), :class:`APIJudge` keeps the OpenAI-/Anthropic-compatible door open. +This module imports nothing heavy at import time; the GGUF runtime and the HTTP +client are lazy-loaded at first use so importing :mod:`dsagt.judge` (and merely +constructing a judge) stays cheap. + +:class:`LocalJudge` is **live** (grammar-constrained GGUF inference); +:class:`APIJudge`'s ``distill`` is still a no-op (returns ``[]``) pending demand +— the abstraction, factory, prompt builder, and parser are shared, so filling it +in is a fill-in, not a refactor. + +Class map — ``▷`` inherits · ``◇`` holds:: + + Judge (ABC) backend selector + distill() contract + ├─▷ LocalJudge grammar-constrained GGUF (llama-cpp-python) + └─▷ APIJudge OpenAI/Anthropic-compatible endpoint (stub) + + build_distill_prompt(exchanges, tags) → str the lean per-turn prompt + parse_distill_response(text, tags) → [{text, tag}] tolerant JSON parse +""" + +from __future__ import annotations + +import json +import logging +from abc import ABC, abstractmethod + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Prompt construction + response parsing (pure — shared by every backend) +# --------------------------------------------------------------------------- + + +def _render_turn(exchanges: list[dict]) -> str: + """Flatten a turn's exchanges into a compact ``[role] text`` transcript. + + Reuses the content-block shape :meth:`CanonicalTrace.to_exchanges` emits + (``new_messages`` / ``response`` lists of Anthropic blocks). Kept local to + the judge rather than importing memory's richer renderer: the judge wants a + terse view (it classifies, it doesn't audit), and the seam stays one-way. + """ + lines: list[str] = [] + for ex in exchanges: + for msg in ex.get("new_messages", []): + role = msg.get("role", "user") + for block in msg.get("content", []): + if block.get("type") == "text" and block.get("text"): + lines.append(f"[{role}] {block['text']}") + elif block.get("type") == "tool_use": + lines.append(f"[{role} → {block.get('name', 'tool')}]") + for block in ex.get("response", []): + if block.get("type") == "text" and block.get("text"): + lines.append(f"[assistant] {block['text']}") + elif block.get("type") == "tool_use": + lines.append(f"[assistant → {block.get('name', 'tool')}]") + return "\n".join(lines) + + +def build_distill_prompt(exchanges: list[dict], tags: dict[str, str]) -> str: + """The lean per-turn distillation prompt. + + Deliberately *not* ``memory.build_extraction_prompt`` (facts + summary + + insights + classify is too much for a small model). One job: pick a tag + from the closed set and copy out ≤1 declarative sentence per fact, or return + ``[]`` when the turn carries nothing worth remembering. + """ + tag_list = "\n".join(f"- {name}: {desc}" for name, desc in tags.items()) + transcript = _render_turn(exchanges) + return f"""\ +You extract durable facts from one turn of a data-pipeline assistant session. + +Rules: +- Output ONLY a JSON array, no prose, no markdown fences. +- Each element is {{"fact": "", "tag": ""}}. +- Copy facts out of the turn (extract); do not summarize or infer. +- "tag" MUST be exactly one of the tags below — never invent one. +- Most turns carry nothing durable. When in doubt, output []. + +Tags: +{tag_list} + +Turn: +{transcript} + +JSON array:""" + + +def parse_distill_response(text: str, tags: dict[str, str]) -> list[dict]: + """Parse the model's JSON array into validated ``[{text, tag}]`` facts. + + Tolerant of a markdown fence around the array. Drops malformed elements + and any fact whose tag is outside the taxonomy (the closed set is what makes + the small model viable — an off-set tag is a model error, not a new tag). + """ + cleaned = text.strip() + if cleaned.startswith("```"): + cleaned = cleaned.split("\n", 1)[1] if "\n" in cleaned else cleaned[3:] + if cleaned.endswith("```"): + cleaned = cleaned[:-3] + cleaned = cleaned.strip() + if not cleaned: + return [] + + parsed = json.loads(cleaned) + if not isinstance(parsed, list): + return [] + + facts: list[dict] = [] + for item in parsed: + if not isinstance(item, dict): + continue + fact = (item.get("fact") or "").strip() + tag = (item.get("tag") or "").strip() + if fact and tag in tags: + facts.append({"text": fact, "tag": tag}) + return facts + + +# --------------------------------------------------------------------------- +# Judge abstraction +# --------------------------------------------------------------------------- + + +class Judge(ABC): + """A small generative LLM that distills a turn into tagged facts. + + Stateless after construction (the model/connection is the only state). A + single judge instance is shared by one :class:`~dsagt.memory.MemoryExtractor` + across every turn it processes. + """ + + #: Short backend tag for span labelling / config display. + backend: str = "unknown" + #: Model identifier; subclasses set this in ``__init__``. + model: str | None = None + + @classmethod + def create( + cls, + backend: str, + *, + model: str | None = None, + base_url: str | None = None, + api_key: str | None = None, + provider: str | None = None, + ) -> "Judge": + """Factory: construct the one judge for an extractor, with explicit args. + + Mirrors :meth:`Embedder.create` — each backend pulls only the parameters + it uses, no ``**kwargs`` splat. Defaults to ``local`` (the adoption + bet: no API key required). + """ + backend = (backend or "local").lower() + if backend == "local": + return LocalJudge(model=model) + if backend == "api": + return APIJudge( + model=model, base_url=base_url, api_key=api_key, provider=provider + ) + raise ValueError(f"Unknown judge backend {backend!r}. Choose from: local, api") + + @abstractmethod + def distill(self, exchanges: list[dict], tags: dict[str, str]) -> list[dict]: + """Distill one turn's ``exchanges`` into ``[{text, tag}]`` facts (or ``[]``).""" + + def close(self) -> None: + pass + + +class LocalJudge(Judge): + """Embeddable GGUF inference via ``llama-cpp-python`` — runs fully offline. + + The local weight is real (a generative LLM, GBs on disk + an inference + runtime), unlike the light ``LocalEmbedder``; it loads lazily on first + ``distill`` so constructing a ``LocalJudge`` (e.g. when memory is wired but + Tier-1 stays disabled) costs nothing. + """ + + backend = "local" + + #: A small instruction-tuned, quantized GGUF — the class the plan calls for + #: (reliable at closed-set classification, cheap to run on CPU; ~1GB Q4). + #: GBNF grammar guarantees valid JSON regardless of size, so 1.5B is a safe + #: quality floor for the one-sentence condense rather than a capability bet. + #: Pulled from the HF hub on first use and cached; override via the + #: ``judge.model`` config (e.g. a 0.5B for speed, or a local .gguf path). + DEFAULT_REPO = "Qwen/Qwen2.5-1.5B-Instruct-GGUF" + DEFAULT_FILE = "qwen2.5-1.5b-instruct-q4_k_m.gguf" + + def __init__(self, model: str | None = None): + # ``model``, when set, is a local .gguf path; otherwise the default HF + # repo/file pair is fetched lazily. Stored only — no I/O here. + self.model = model or self.DEFAULT_REPO + self._model_path = model + self._llm = None # lazily-loaded llama_cpp.Llama + self._grammars: dict[tuple, object] = {} # tag-set → compiled LlamaGrammar + + def _ensure_llm(self): + """Load the GGUF runtime on first use (heavy: native lib + GB weights).""" + if self._llm is not None: + return self._llm + try: + from llama_cpp import Llama + except ImportError as e: # actionable — it's a core dep, so this is an + # incomplete install, not a missing opt-in. + raise RuntimeError( + "LocalJudge needs llama-cpp-python (a core dependency) — " + "reinstall with `uv sync`." + ) from e + if self._model_path: + self._llm = Llama(model_path=self._model_path, n_ctx=8192, verbose=False) + else: + self._llm = Llama.from_pretrained( + repo_id=self.DEFAULT_REPO, + filename=self.DEFAULT_FILE, + n_ctx=8192, + verbose=False, + ) + return self._llm + + def _grammar(self, tags: dict[str, str]): + """A GBNF grammar pinning output to ``[{fact: str, tag: }]``. + + Compiled from a JSON schema whose ``tag`` is an enum of the taxonomy, so + the model *cannot* emit malformed JSON or an off-set tag — the single + biggest reliability lever for a small model (``parse_distill_response`` + is then just defence in depth). Cached per tag-set since the taxonomy is + fixed for a project. + """ + key = tuple(sorted(tags)) + if key not in self._grammars: + from llama_cpp import LlamaGrammar + + schema = { + "type": "array", + "items": { + "type": "object", + "properties": { + "fact": {"type": "string"}, + "tag": {"type": "string", "enum": list(tags)}, + }, + "required": ["fact", "tag"], + "additionalProperties": False, + }, + } + self._grammars[key] = LlamaGrammar.from_json_schema(json.dumps(schema)) + return self._grammars[key] + + def distill(self, exchanges: list[dict], tags: dict[str, str]) -> list[dict]: + if not exchanges or not tags: + return [] + llm = self._ensure_llm() + out = llm.create_chat_completion( + messages=[ + {"role": "user", "content": build_distill_prompt(exchanges, tags)} + ], + max_tokens=512, + temperature=0.0, # deterministic extraction, not creative writing + grammar=self._grammar(tags), + ) + return parse_distill_response(out["choices"][0]["message"]["content"], tags) + + +class APIJudge(Judge): + """OpenAI-/Anthropic-compatible endpoint — keeps the remote door open. + + Reuses the project's LLM gateway settings; carries a credential, so it is + never the default (see the plan's adoption argument for local-by-default). + """ + + backend = "api" + + DEFAULT_MODEL = "claude-haiku-4-5-20251001" + + def __init__( + self, + model: str | None = None, + base_url: str | None = None, + api_key: str | None = None, + provider: str | None = None, + ): + self.model = model or self.DEFAULT_MODEL + self._base_url = base_url + self._api_key = api_key + self._provider = provider + + def distill(self, exchanges: list[dict], tags: dict[str, str]) -> list[dict]: + # Tier-1 not yet wired. When it is, mirror the old + # ``memory.call_extraction_llm`` shape (litellm completion against the + # configured gateway), then ``parse_distill_response`` the result. + del exchanges, tags + return [] diff --git a/src/dsagt/knowledge.py b/src/dsagt/knowledge.py index b6e9c83..5313069 100644 --- a/src/dsagt/knowledge.py +++ b/src/dsagt/knowledge.py @@ -1,8 +1,66 @@ """ -Knowledge base: semantic search over document collections. - -Pluggable embedding backends (local sentence-transformers or OpenAI-compatible API) -and vector-DB backends (FAISS or ChromaDB) with per-collection routing. +Knowledge base — hybrid semantic retrieval over document collections, and the +shared substrate every other DSAGT capability searches against. + +A :class:`KnowledgeBase` holds one or more vector stores (each a single embedder +over many collections) and fuses their results by Reciprocal Rank Fusion (RRF). +Fusion runs at two levels: *within* a collection, dense vector similarity is +blended with a BM25 sparse leg, so exact identifiers and error strings that dense +embeddings under-rank still surface; *across* collections and stores, per- +collection rankings are fused by rank alone — letting results from different +embedding spaces combine without any cross-space score normalization. The same +surface backs document/domain-knowledge retrieval, explicit and episodic memory, +tool-usage provenance, and skills discovery. Design-wise it keeps maintenance +bounded: one embedder per store, a bring-your-own external store is just another +:class:`VectorStore` subclass added to the list, and the heavy document parsers +are imported lazily so only actual ingestion pays for them, not server startup. + +The niche it fills +------------------ +General coding agents have settled retrieval into two modes this KB deliberately +does not compete with — agentic ``grep`` / tool search over *code* (Claude Code, +Cursor, Windsurf et al. dropped codebase vector indexing outright) and *web +search* for open-ended questions. Neither serves **bounded, domain-scoped, +vetted corpora of highly technical prose** — protocols, API references, domain +knowledge, tool-use provenance — which is exactly where hybrid dense+sparse +retrieval still wins and where general agents otherwise fall back to untrusted +web search. That curated, tagged, auditable, hit-it-*first* retrieval is the +niche DSAGT fills to supplement general agents on specialized, highly technical +task loads. The design above is the 2026 best-practice answer to the two +documented failure modes: hybrid search (BM25 recovers the exact identifiers +dense vectors miss) and per-collection domain scoping (the antidote to +"vector-search dilution", where pure-vector accuracy collapses on large +heterogeneous corpora). + +References: + - Code agents drop vector indexing for agentic grep — + https://www.mindstudio.ai/blog/is-rag-dead-what-ai-agents-use-instead ; + https://vadim.blog/claude-code-no-indexing/ + - Hybrid BM25 + dense + reranking, the highest-impact RAG upgrade — + https://www.digitalapplied.com/blog/hybrid-search-bm25-vector-reranking-reference-2026 + - "When More Documents Hurt RAG": vector-search dilution, fixed by + domain-scoped retrieval — https://arxiv.org/abs/2606.11350 + - 2026 enterprise knowledge management — trust / traceability favor curated + KBs over web search — + https://windowsforum.com/threads/2026-enterprise-ai-knowledge-management-from-search-to-governed-agent-workflows.410816/ + +Class map — every edge is `` Class``, where ```` is one of +``◇`` holds · ``◆`` owns · ``▷`` inherits (``*`` = one per collection):: + + KnowledgeBase federation infra: ingest pipeline + + │ cross-collection RRF (_rrf_across) + └─◇ VectorStore «abstract» 1..* BYO adapter port: one embedder, many + │ collections; single-collection add/search + └─▷ ChromaVectorStore local Chroma; hybrid dense+BM25 per + │ collection, fused by _rrf_merge + ├─◇ Embedder «abstract» 1 text → vectors; .create() factory + │ │ (one per store; may be shared) + │ ├─▷ LocalEmbedder sentence-transformers, offline + │ └─▷ APIEmbedder OpenAI /v1/embeddings + rate-limit retry + ├─◆ ChromaIndex * dense leg (add/search/save/load) + └─◆ BM25Index * sparse leg (build/search/save/load) + + free fns: _rrf_merge · _rrf_across (rank fusion, used by store + KB) """ from __future__ import annotations @@ -12,65 +70,41 @@ import logging import os import re +import threading import time from abc import ABC, abstractmethod -from dataclasses import asdict, dataclass, field from pathlib import Path from typing import Any, Iterator logger = logging.getLogger(__name__) -# Silence noisy parser libraries. pypdf logs "invalid pdf header", "EOF -# marker not found", and "Ignoring wrong pointing object" at WARNING for -# every malformed PDF it touches — usually image-derived "PDFs" or stripped -# arXiv source. We already count read/parse failures as ``skipped_files`` -# in the ingest result, so these per-file warnings add no signal and a lot -# of noise. Demote to ERROR so genuine PDF library bugs still surface. +# Silence noisy warnings, progress bars from a host of libraries +import warnings as _warnings + for _noisy in ("pypdf", "pypdf2", "PyPDF2", "pdfminer", "fontTools"): logging.getLogger(_noisy).setLevel(logging.ERROR) -# sentence-transformers / huggingface_hub / httpx emit a wall of INFO at -# embedding-model load time: every cached HEAD request to huggingface.co, -# the SentenceTransformer "Loading model from..." line, the cache redirects. -# Demote to WARNING so end-of-session extraction doesn't bury the actual -# extraction result under model-cache validation traffic. Errors still -# surface (genuine network failures, missing models, etc.). -for _noisy in ("httpx", "httpcore", "huggingface_hub", "sentence_transformers", - "transformers", "transformers_modules"): +for _noisy in ( + "httpx", + "httpcore", + "huggingface_hub", + "sentence_transformers", + "transformers", + "transformers_modules", +): logging.getLogger(_noisy).setLevel(logging.WARNING) -# huggingface_hub's "you are sending unauthenticated requests" warning is -# emitted both via ``logger.warning`` and via ``warnings.warn`` (one -# stderr line each). It's informational — anonymous downloads work -# fine, just at lower rate limits — but it surfaces under roo's -# ``--debug`` (which forwards MCP-server stderr) and confuses users -# who don't realise HF_TOKEN is unrelated to whether dsagt works. -# Demote the logger and filter the warning. logging.getLogger("huggingface_hub.utils._http").setLevel(logging.ERROR) -# ``transformers`` BertModel loader writes a "LOAD REPORT" table to stdout -# when missing/unexpected keys differ from the checkpoint — informational -# for ML researchers debugging finetunes, noise for everyone else. Their -# own env var silences it. -import os as _os -_os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error") -# Disable huggingface_hub's tqdm progress bars (the "Loading weights: -# 100%|...| 199/199" line at model load). For cached models this loads -# in <1s — the bar is pure noise. -_os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1") -# Silence the FutureWarning sentence-transformers prints about its own -# renamed method (``get_sentence_embedding_dimension`` → -# ``get_embedding_dimension``); upstream still calls the deprecated form. -import warnings as _warnings +os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error") +os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1") + _warnings.filterwarnings( "ignore", message="The `get_sentence_embedding_dimension` method has been renamed", category=FutureWarning, ) -# Silence huggingface_hub's "Warning: You are sending unauthenticated -# requests to the HF Hub" notice (UserWarning on the ``huggingface_hub`` -# module). Setting HF_TOKEN works too, but we never bake user creds -# into MCP env blocks on disk — see ``_mcp_env_block`` docstring. + _warnings.filterwarnings( "ignore", message=r".*unauthenticated requests to the HF Hub.*", @@ -78,11 +112,13 @@ import numpy as np -# llama_index is intentionally NOT imported at module top. -# It pulls in ~400 transitive submodules and adds ~8s to cold start, which -# matters because dsagt-run imports this module on every tool invocation -# but only ever needs the embedding/search code paths, never the parsers. -# llama_index is lazy-imported inside _chunk_file() and _get_parser(). +# llama_index is intentionally NOT imported at module top: it pulls in ~400 +# transitive submodules and adds ~8s to import. Only document *ingest* +# (kb_ingest / the `dsagt init` KB build) needs the parsers — search, memory, +# provenance indexing, and skills never do — so importing it at module top +# would add ~8s of blocking load to every MCP-server startup (and to +# post-session extraction) for a parser most sessions never invoke. +# Lazy-imported inside _chunk_file() / _get_parser() so only ingest pays for it. from dsagt.observability import ( kb_embed_span, @@ -92,55 +128,79 @@ traced, ) - CODE_LANGUAGES = { - ".py": "python", ".js": "javascript", ".ts": "typescript", - ".jsx": "javascript", ".tsx": "typescript", ".rs": "rust", - ".go": "go", ".java": "java", ".cpp": "cpp", ".c": "c", - ".rb": "ruby", ".php": "php", + ".py": "python", + ".js": "javascript", + ".ts": "typescript", + ".jsx": "javascript", + ".tsx": "typescript", + ".rs": "rust", + ".go": "go", + ".java": "java", + ".cpp": "cpp", + ".c": "c", + ".rb": "ruby", + ".php": "php", } -class BaseEmbeddingClient(ABC): - """Common interface for all embedding backends.""" - - @abstractmethod - def embed(self, texts: list[str]) -> np.ndarray: - """Return float32 array of shape (n_texts, dim), L2-normalized.""" +# =========================================================================== +# Embedder — one per VectorStore +# =========================================================================== - def close(self) -> None: - pass +class Embedder(ABC): + """Common interface for all embedding backends. -class BaseVectorIndex(ABC): - """Common interface for all vector-index backends.""" + A stateless-after-construction leaf: a single :class:`VectorStore` has-a one + ``Embedder``, and the *same* instance can be shared by several stores (e.g. a + Chroma store and a FAISS store on one embedding space). + """ - @abstractmethod - def add(self, embeddings: np.ndarray) -> None: - """Append pre-normalized embeddings.""" + #: Short backend tag used for tracing spans ("local" / "api"). + backend: str = "unknown" + #: Model identifier, for span labelling / collection listing. Subclasses + #: set this in ``__init__``. + model: str | None = None - @abstractmethod - def search(self, query_vec: np.ndarray, k: int) -> tuple[np.ndarray, np.ndarray]: - """Return (scores, int_indices) arrays of length k.""" + @classmethod + def create( + cls, + backend: str, + *, + model: str | None = None, + base_url: str | None = None, + api_key: str | None = None, + device: str | None = None, + ) -> "Embedder": + """Factory: construct the one embedder for a store, with explicit args. - @abstractmethod - def save(self, directory: Path) -> None: - """Persist index to *directory*.""" + The api-vs-local selector that stays after per-collection embedder + routing was removed: a store fixes a single embedder at construction. + Each backend pulls only the parameters it uses — no ``**kwargs`` splat. + """ + backend = (backend or "api").lower() + if backend == "local": + return LocalEmbedder(model=model, device=device) + if backend == "api": + return APIEmbedder(model=model, base_url=base_url, api_key=api_key) + raise ValueError( + f"Unknown embedding backend {backend!r}. Choose from: local, api" + ) - @classmethod @abstractmethod - def load(cls, directory: Path) -> "BaseVectorIndex": - """Load index from *directory*.""" + def embed(self, texts: list[str]) -> np.ndarray: + """Return float32 array of shape (n_texts, dim), L2-normalized.""" - @property - @abstractmethod - def size(self) -> int: - """Number of vectors stored.""" + def close(self) -> None: + pass -class LocalEmbeddingClient(BaseEmbeddingClient): +class LocalEmbedder(Embedder): """sentence-transformers, runs fully offline.""" + backend = "local" + #: Default local model. ``bge-small-en-v1.5`` (33M params, ~130 MB #: on disk, ~250 MB resident) is ~3× faster and ~3× smaller than the #: ``bge-base`` variant we used previously, with ~2 nDCG@10 points @@ -159,30 +219,36 @@ def __init__( ): import sys from sentence_transformers import SentenceTransformer + model = model or self.DEFAULT_MODEL + self.model = model self.batch_size = batch_size # Probe the HF cache for the model's config. If hit, load with # ``local_files_only=True`` so SentenceTransformer skips the # ETag-validation HEAD requests it would otherwise issue against # huggingface.co — those round-trips are anonymous (HF_TOKEN - # isn't propagated into MCP-server env blocks for cline / roo / + # isn't propagated into MCP-server env blocks for cline / # codex), so they trigger an "unauthenticated requests" warning - # surfaced under roo's ``--debug``. Cache miss path stays + # surfaced under the agent's debug stream. Cache miss path stays # online so first-run downloads still work. try: from huggingface_hub import try_to_load_from_cache + cache_hit = try_to_load_from_cache(model, "config.json") is not None except Exception: cache_hit = False if cache_hit: self._model = SentenceTransformer( - model, device=device, local_files_only=True, + model, + device=device, + local_files_only=True, ) else: print( f" Downloading {model} from HuggingFace " "(set HF_TOKEN for faster throughput)...", - file=sys.stderr, flush=True, + file=sys.stderr, + flush=True, ) self._model = SentenceTransformer(model, device=device) # Belt-and-suspenders: dsagt/__init__.py sets OMP_NUM_THREADS / @@ -191,20 +257,20 @@ def __init__( # vars in some configurations. Cap explicitly here. try: import torch + torch.set_num_threads(int(os.environ.get("OMP_NUM_THREADS", "4"))) except Exception: # noqa: BLE001 — best-effort cap, never fatal pass logger.info( "Loaded local embedding model: %s (dim=%d)", - model, self._model.get_sentence_embedding_dimension(), + model, + self._model.get_embedding_dimension(), ) def embed(self, texts: list[str]) -> np.ndarray: - if not texts: - return np.array([], dtype=np.float32) # Show the progress bar only for non-trivial inputs so single-query # search calls (kb.search → embed([query])) stay silent while large - # ingest / setup-kb runs surface tqdm progress. + # ingest runs surface tqdm progress. show_bar = len(texts) > self.batch_size return self._model.encode( texts, @@ -214,19 +280,18 @@ def embed(self, texts: list[str]) -> np.ndarray: ).astype(np.float32) -# --- rate-limit retry helpers (used by APIEmbeddingClient) ---------------- +# --- rate-limit retry helpers (used by APIEmbedder) ---------------- # -# We can't rely on litellm.num_retries alone for embedding rate limits. Lab -# LiteLLM proxies (notably PNNL's Azure-fronted instance) wrap upstream 429s -# as APIConnectionError carrying the rate-limit error in a JSON message body, -# which defeats litellm's exception-class-based retry detection. Even when -# litellm does retry, its default exponential backoff caps at ~30s total -# across 5 attempts, while Azure's per-minute quota window asks for 60s+ -# between retries. So we own this retry layer ourselves and disable -# litellm's by passing ``num_retries=0`` per call. +# The API embedder talks to an OpenAI-compatible ``/v1/embeddings`` endpoint +# over httpx. We own the retry layer because lab gateways (notably PNNL's +# Azure-fronted instance) return upstream 429s with the quota window in the +# body and ask for 60s+ between retries — longer than a generic exponential +# backoff would wait. Rate-limit / transient errors retry with a +# retry-after-aware wait; auth / bad-request errors fail fast. _RETRY_AFTER_RE = re.compile( - r"retry after (\d+(?:\.\d+)?)\s*seconds?", re.IGNORECASE, + r"retry after (\d+(?:\.\d+)?)\s*seconds?", + re.IGNORECASE, ) @@ -246,31 +311,22 @@ def _extract_retry_after_seconds(message: str, default: float = 60.0) -> float: def _is_retryable_embedding_error(exc: Exception) -> bool: """Decide whether an embedding exception is worth retrying. - Detects rate-limit and transient errors across the various shapes - LiteLLM exposes them in. Authentication / bad-request errors are - explicitly NOT retryable so we fail fast on misconfiguration. + Retries rate-limit (429) and transient server / network errors; + authentication and bad-request errors are explicitly NOT retryable so + we fail fast on misconfiguration. """ - # litellm is a hard dependency — broken install if this fails. - import litellm - - if isinstance(exc, ( - litellm.exceptions.AuthenticationError, - litellm.exceptions.BadRequestError, - litellm.exceptions.NotFoundError, - litellm.exceptions.PermissionDeniedError, - )): - return False - - if isinstance(exc, ( - litellm.exceptions.RateLimitError, - litellm.exceptions.APIConnectionError, - litellm.exceptions.Timeout, - litellm.exceptions.ServiceUnavailableError, - litellm.exceptions.InternalServerError, - )): + import httpx + + if isinstance(exc, httpx.HTTPStatusError): + code = exc.response.status_code + # 408 request timeout, 425 too early, 429 rate limit, 5xx server. + return code in (408, 425, 429, 500, 502, 503, 504) + + # Connect / read timeouts and other transport failures are transient. + if isinstance(exc, (httpx.TimeoutException, httpx.TransportError)): return True - # Some lab proxies wrap rate limits as plain Exceptions with the upstream + # Some gateways wrap rate limits in a plain Exception with the upstream # body in str(exc). Fall back to message inspection. msg = str(exc).lower() return any(k in msg for k in ("rate limit", "ratelimiterror", "429", "throttl")) @@ -279,39 +335,48 @@ def _is_retryable_embedding_error(exc: Exception) -> bool: def _retry_wait_seconds(exc: Exception, attempt: int) -> float: """How long to sleep before the next retry attempt. - Rate-limit errors get the upstream-suggested wait (or 60s default). - Other transient errors get exponential backoff capped at 30s. + Honors a ``Retry-After`` header (or a retry-after hint in the body) on + rate-limit responses; other transient errors get exponential backoff + capped at 30s. """ + import httpx + + if isinstance(exc, httpx.HTTPStatusError): + retry_after = exc.response.headers.get("retry-after") + if retry_after: + try: + return float(retry_after) + except ValueError: + pass + if exc.response.status_code == 429: + return _extract_retry_after_seconds(exc.response.text, default=60.0) msg = str(exc).lower() if "rate limit" in msg or "429" in msg or "throttl" in msg: return _extract_retry_after_seconds(str(exc), default=60.0) - return min(2.0 ** attempt, 30.0) + return min(2.0**attempt, 30.0) -class APIEmbeddingClient(BaseEmbeddingClient): - """OpenAI-compatible embedding client backed by LiteLLM. +class APIEmbedder(Embedder): + """Embedding client for an OpenAI-compatible ``/v1/embeddings`` endpoint. - LiteLLM normalizes a wide set of providers (OpenAI, Azure, Bedrock, - Vertex, Cohere, Voyage, Ollama, Together, etc.) behind a single - ``embedding(...)`` call. This client adds two things on top: + Talks to the endpoint directly over httpx — no provider-abstraction + layer. The model string is sent verbatim in the request body, so + gateway aliases (``text-embedding-3-small-project``) and HuggingFace- + style names with slashes (``nomic-ai/nomic-embed-text-v1``) pass + through unchanged. Two things on top of the raw call: 1. **Manual batching** of large inputs into ``batch_size``-sized requests so a single rate-limit hit only loses one batch and the user can see per-batch progress in the logs. 2. **Explicit rate-limit retry** with retry-after-aware backoff - (see ``_embed_batch_with_retry``). This is the layer that keeps - large ``kb_ingest`` and ``dsagt-setup-kb`` runs alive in the face - of TPM quotas — litellm's built-in retry isn't sufficient because - lab proxies wrap upstream 429s in a way that defeats its - exception-class detection, and its backoff is too short for the - 60-second quota windows Azure enforces. - - The model string passed to LiteLLM determines provider routing. Bare - model names get an ``openai_like/`` prefix so the lab proxy receives - the model alias unchanged. + (see ``_embed_batch_with_retry``) — what keeps large ``kb_ingest`` + and ``dsagt init`` KB builds alive against the 60-second quota + windows lab gateways enforce. """ + backend = "api" + def __init__( self, model: str | None = None, @@ -320,12 +385,17 @@ def __init__( timeout: float = 300.0, batch_size: int = 100, ): - self.model = model or os.getenv("EMBEDDING_MODEL", "text-embedding-3-small-project") - self.base_url = base_url or os.getenv("OPENAI_BASE_URL") + import httpx + + self.model = model or os.getenv( + "EMBEDDING_MODEL", "text-embedding-3-small-project" + ) + self.base_url = ( + base_url or os.getenv("EMBEDDING_BASE_URL") or os.getenv("OPENAI_BASE_URL") + ) # EMBEDDING_API_KEY is the canonical name; LLM_API_KEY/OPENAI_API_KEY # are legacy fallbacks from when the embedding endpoint shared the - # LLM endpoint's auth. When embeddings route through dsagt's proxy - # (the default), this is a sentinel — the proxy holds the real key. + # LLM endpoint's auth. self.api_key = ( api_key or os.getenv("EMBEDDING_API_KEY") @@ -336,30 +406,21 @@ def __init__( self.batch_size = batch_size if not self.base_url: - raise ValueError("Embedding API base URL required via argument or OPENAI_BASE_URL env var") + raise ValueError( + "Embedding API base URL required via argument or " + "EMBEDDING_BASE_URL / OPENAI_BASE_URL env var" + ) if not self.api_key: - raise ValueError("API key required via argument or LLM_API_KEY env var") - - # Always prefix with ``openai_like`` so LiteLLM dispatches to the - # OpenAI-wire-protocol client pointed at our ``base_url`` — the rest - # of DSAGT already assumes the embedding endpoint is OpenAI-compat, - # so there's no valid case for a different provider here. - # - # ``openai_like`` (not ``openai``) is deliberate on two counts: (a) - # ``openai`` matches LiteLLM's canonical model registry and silently - # normalizes aliases like ``text-embedding-3-small-project`` down to - # ``text-embedding-3-small``, which lab proxies then reject; (b) - # ``openai_like`` is the documented escape hatch for "endpoint - # speaks OpenAI but is not OpenAI" — it forwards the model string - # verbatim, preserving HuggingFace-style names (``lbl/nomic-embed-text``, - # ``nomic-ai/nomic-embed-text-v1``) whose slashes are part of the - # model identifier, not a provider prefix. - self._litellm_model = f"openai_like/{self.model}" + raise ValueError( + "API key required via argument or EMBEDDING_API_KEY env var" + ) - def embed(self, texts: list[str]) -> np.ndarray: - if not texts: - return np.array([], dtype=np.float32) + # ``base_url`` is the OpenAI-style root (typically ending in ``/v1``); + # the embeddings route hangs off it. + self._embeddings_url = self.base_url.rstrip("/") + "/embeddings" + self._client = httpx.Client(timeout=timeout) + def embed(self, texts: list[str]) -> np.ndarray: # Single small input: one call, no batch logging. if len(texts) <= self.batch_size: return self._embed_batch_with_retry(texts) @@ -369,11 +430,13 @@ def embed(self, texts: list[str]) -> np.ndarray: n_batches = (len(texts) + self.batch_size - 1) // self.batch_size batches: list[np.ndarray] = [] for i in range(0, len(texts), self.batch_size): - batch = texts[i:i + self.batch_size] + batch = texts[i : i + self.batch_size] batch_num = i // self.batch_size + 1 logger.info( "Embedding batch %d/%d (%d texts)", - batch_num, n_batches, len(batch), + batch_num, + n_batches, + len(batch), ) batches.append(self._embed_batch_with_retry(batch)) return np.vstack(batches) @@ -386,24 +449,22 @@ def _embed_batch_with_retry( """Embed one batch with explicit rate-limit and transient-error retry. See the module-level rate-limit retry helpers for the rationale. - We pass ``num_retries=0`` to litellm so its built-in retry doesn't - race with ours and we don't end up double-retrying with conflicting - backoff strategies. """ - import litellm + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + payload = {"model": self.model, "input": texts} attempt = 0 while True: attempt += 1 try: - response = litellm.embedding( - model=self._litellm_model, - input=texts, - api_base=self.base_url, - api_key=self.api_key, - timeout=self.timeout, - num_retries=0, + resp = self._client.post( + self._embeddings_url, json=payload, headers=headers ) + resp.raise_for_status() + data = resp.json() break # success except Exception as exc: if not _is_retryable_embedding_error(exc) or attempt >= max_attempts: @@ -412,69 +473,35 @@ def _embed_batch_with_retry( logger.warning( "Embedding error (attempt %d/%d). " "Waiting %.0fs then retrying. Cause: %s", - attempt, max_attempts, wait, str(exc)[:200], + attempt, + max_attempts, + wait, + str(exc)[:200], ) time.sleep(wait) - # Response shape mirrors OpenAI: response.data is a list of objects - # with .embedding (or ['embedding'] for dict access). - sorted_data = sorted( - response.data, - key=lambda d: d["index"] if isinstance(d, dict) else d.index, - ) - vectors = [ - d["embedding"] if isinstance(d, dict) else d.embedding - for d in sorted_data - ] + # Response shape is OpenAI's: ``data`` is a list of ``{"index": i, + # "embedding": [...]}`` objects, not guaranteed to be in input order. + sorted_data = sorted(data["data"], key=lambda d: d["index"]) + vectors = [d["embedding"] for d in sorted_data] return np.array(vectors, dtype=np.float32) def close(self) -> None: - # No client object to close anymore — LiteLLM owns its own pool. - pass - -class FAISSIndex(BaseVectorIndex): - """Inner-product FAISS flat index (cosine similarity on L2-normed vecs).""" + self._client.close() - _FILENAME = "index.faiss" - def __init__(self, dim: int | None = None): - self._dim = dim - self._index = None # lazy-init on first add - - def _ensure_init(self, dim: int) -> None: - if self._index is None: - import faiss - self._dim = dim - self._index = faiss.IndexFlatIP(dim) - - def add(self, embeddings: np.ndarray) -> None: - self._ensure_init(embeddings.shape[1]) - self._index.add(embeddings) - - def search(self, query_vec: np.ndarray, k: int) -> tuple[np.ndarray, np.ndarray]: - k = min(k, self._index.ntotal) - scores, indices = self._index.search(query_vec.reshape(1, -1), k) - return scores[0], indices[0] - - def save(self, directory: Path) -> None: - import faiss - faiss.write_index(self._index, str(directory / self._FILENAME)) - - @classmethod - def load(cls, directory: Path) -> "FAISSIndex": - import faiss - obj = cls() - obj._index = faiss.read_index(str(directory / cls._FILENAME)) - obj._dim = obj._index.d - return obj +# =========================================================================== +# ChromaIndex — single-collection dense vector wrapper (ChromaVectorStore's leg) +# =========================================================================== - @property - def size(self) -> int: - return 0 if self._index is None else self._index.ntotal +class ChromaIndex: + """ChromaDB-backed dense index for ONE collection. Requires ``chromadb``. -class ChromaIndex(BaseVectorIndex): - """ChromaDB-backed index. Requires ``chromadb`` package.""" + A plain helper owned by :class:`ChromaVectorStore` — not a pluggable + "vector-DB backend". Stores cosine-space HNSW vectors plus a positional + id list so returned integer indices line up with the chunk list. + """ _META_FILE = "chroma_ids.json" # maps int position → chroma id @@ -508,14 +535,16 @@ def add(self, embeddings: np.ndarray, metadatas: list[dict] | None = None) -> No batch_size = 5000 for i in range(0, len(new_ids), batch_size): kwargs: dict = { - "ids": new_ids[i:i + batch_size], - "embeddings": embeddings_list[i:i + batch_size], + "ids": new_ids[i : i + batch_size], + "embeddings": embeddings_list[i : i + batch_size], } if metadatas is not None: - kwargs["metadatas"] = metadatas[i:i + batch_size] + kwargs["metadatas"] = metadatas[i : i + batch_size] self._col.add(**kwargs) - def search(self, query_vec: np.ndarray, k: int, where: dict | None = None) -> tuple[np.ndarray, np.ndarray]: + def search( + self, query_vec: np.ndarray, k: int, where: dict | None = None + ) -> tuple[np.ndarray, np.ndarray]: k = min(k, len(self._ids)) if k == 0: return np.array([], dtype=np.float32), np.array([], dtype=np.int64) @@ -547,6 +576,10 @@ def size(self) -> int: return len(self._ids) +# =========================================================================== +# BM25 — the sparse leg fused with dense vectors per collection +# =========================================================================== + # BM25 sparse-retrieval token splitter. Splits on every non-alphanumeric # character so ``snake_case`` and ``kebab-case`` identifiers fan out into # their parts; this matters because much of what an agent searches the KB @@ -563,15 +596,15 @@ def _bm25_tokenize(text: str) -> list[str]: class BM25Index: """Sparse BM25 keyword index over a collection's chunk texts. - Maintained alongside the dense vector index so :meth:`KnowledgeBase.search` - can fuse the two via Reciprocal Rank Fusion. Stored as a single - pickle file (``bm25.pkl``) per collection. + Maintained alongside the dense vector index so single-collection search + can fuse the two via Reciprocal Rank Fusion. Stored as a single pickle + file (``bm25.pkl``) per collection — its presence is also what marks a + collection as *hybrid*. - Rebuilt from scratch on every ``add_entries`` / ``append`` because BM25 - IDF stats are corpus-global — there is no cheap incremental update. - For DSAGT corpus sizes (single-digit thousands of chunks) the rebuild - is millisecond-scale, so this is fine. Watch the cost if a collection - grows past tens of thousands of entries. + Rebuilt from scratch on every write because BM25 IDF stats are + corpus-global — there is no cheap incremental update. For DSAGT corpus + sizes (single-digit thousands of chunks) the rebuild is millisecond-scale. + Watch the cost if a collection grows past tens of thousands of entries. """ _FILENAME = "bm25.pkl" @@ -582,6 +615,7 @@ def __init__(self): def build(self, texts: list[str]) -> None: from rank_bm25 import BM25Okapi + if not texts: self._bm25 = None self._n = 0 @@ -613,12 +647,14 @@ def search(self, query: str, k: int) -> tuple[np.ndarray, np.ndarray]: def save(self, directory: Path) -> None: import pickle + with open(directory / self._FILENAME, "wb") as f: pickle.dump({"bm25": self._bm25, "n": self._n}, f) @classmethod def load(cls, directory: Path) -> "BM25Index": import pickle + obj = cls() path = directory / cls._FILENAME if path.exists(): @@ -633,6 +669,11 @@ def size(self) -> int: return self._n +# =========================================================================== +# Reciprocal Rank Fusion — within a collection AND across collections/stores +# =========================================================================== + + def _rrf_merge( rankings: list[list[int]], k: int = 60, @@ -658,383 +699,671 @@ def _rrf_merge( return sorted(scores.items(), key=lambda kv: kv[1], reverse=True) -# map short names to embedding client constructors -EMBEDDER_REGISTRY: dict[str, type[BaseEmbeddingClient]] = { - "local": LocalEmbeddingClient, - "api": APIEmbeddingClient, -} +def _chunk_key(chunk: dict) -> tuple: + """Stable identity for a result chunk, for cross-collection RRF. -# map short names to vector-index constructors -VECTORINDEX_REGISTRY: dict[str, type[BaseVectorIndex]] = { - "faiss": FAISSIndex, - "chroma": ChromaIndex, -} + Different collections (and, later, different stores/embedders) live in + incomparable score spaces, so fusion is rank-only. We key on the chunk's + own metadata rather than score; ``id``-based fallback keeps distinct chunks + distinct even when metadata is sparse. + """ + meta = chunk.get("metadata", {}) + return ( + meta.get("collection"), + meta.get("source_file"), + meta.get("chunk_index"), + chunk.get("text", "")[:64], + ) -def _make_embedder(backend: str, **kwargs) -> BaseEmbeddingClient: - backend = backend.lower() - if backend not in EMBEDDER_REGISTRY: - raise ValueError(f"Unknown embedding backend '{backend}'. " - f"Choose from: {list(EMBEDDER_REGISTRY)}") - return EMBEDDER_REGISTRY[backend](**kwargs) +def _rrf_across(result_lists: list[list[dict]], k: int = 60) -> list[dict]: + """Rank-fuse per-collection result lists (each ``[{chunk, score}, ...]``). + Rank-only RRF across the lists — no cross-space score normalization, which + is exactly what lets results from different embedding spaces fuse correctly. + Returns a single list of result dicts (``score`` replaced by the RRF score), + sorted by descending fused score. + """ + scores: dict[tuple, float] = {} + rep: dict[tuple, dict] = {} + for results in result_lists: + for rank, r in enumerate(results): + key = _chunk_key(r["chunk"]) + scores[key] = scores.get(key, 0.0) + 1.0 / (k + rank + 1) + rep.setdefault(key, r) + ordered = sorted(scores.items(), key=lambda kv: kv[1], reverse=True) + return [{**rep[key], "score": float(score)} for key, score in ordered] + + +# --------------------------------------------------------------------------- +# Recency weighting (episodic session_memory only) +# --------------------------------------------------------------------------- + +#: The one collection recency applies to. Mirrors +#: ``memory.SESSION_MEMORY_COLLECTION`` as a literal to avoid a knowledge→memory +#: import (memory imports knowledge, not the reverse). +_RECENCY_COLLECTION = "session_memory" + +#: Max fractional boost a brand-new fact gets over its raw relevance. A *boost*, +#: never a penalty: recency only lifts recent facts, so a strongly-relevant old +#: fact (e.g. a day-1 threshold that never changed) is never buried — it keeps +#: its full relevance score while a same-relevance newer fact edges ahead. +_RECENCY_BOOST = 0.5 + + +def _apply_recency( + results: list[dict], + half_life_days: float, + now: float, + boost: float = _RECENCY_BOOST, +) -> list[dict]: + """Re-rank ``[{chunk, score}, ...]`` by relevance × a recency factor. + + ``factor = 1 + boost · 2^(-age / half_life)`` — newest facts get up to + ``1+boost``, decaying toward ``1`` (no change) with the given half-life. + Facts without a numeric ``ts_epoch`` get factor ``1.0`` (unweighted), never + dropped. Pure + side-effect-free so it's unit-testable without an embedder. + """ + hl_seconds = max(1.0, half_life_days * 86400.0) + weighted = [] + for r in results: + ts = r["chunk"].get("metadata", {}).get("ts_epoch") + if ts is None: + factor = 1.0 + else: + age = max(0.0, now - float(ts)) + factor = 1.0 + boost * (0.5 ** (age / hl_seconds)) + weighted.append({**r, "score": r["score"] * factor, "recency_factor": factor}) + weighted.sort(key=lambda r: r["score"], reverse=True) + return weighted + + +# =========================================================================== +# VectorStore — one embedder, many collections (the BYO adapter port) +# =========================================================================== + + +class VectorStore(ABC): + """One :class:`Embedder`, many collections: store + single-collection search. + + The adapter contract a BYO store implements: subclass this and wrap your + own backend. :class:`KnowledgeBase` holds a list of these and fuses their + collections. Heterogeneity (mixed embedding spaces) is expressed by having + *several* stores in the list, never by routing within one store. + + TODO (deferred to the second concrete store — BYO/FAISS, see + knowledge-base-plan.md): most of :class:`ChromaVectorStore` is backend- + agnostic — the BM25 sparse leg, the dense+sparse hybrid fusion, the + ``chunks.jsonl`` payload, ``_normalize``. Only the dense index + (add/query/persist over Chroma's HNSW) is Chroma-specific. When a second + store lands, lift that reusable machinery into a shared base that defers + only the dense primitives to subclasses, so a FAISS/BYO store reuses it + rather than copying it. Not extracted now: with one impl the seam can't be + validated. Dense-only stores (external adapters with no local sparse leg) + are modelled as a *separate store type*, not a per-instance flag. + """ -def _make_index(backend: str, **kwargs) -> BaseVectorIndex: - backend = backend.lower() - if backend not in VECTORINDEX_REGISTRY: - raise ValueError(f"Unknown vector-index backend '{backend}'. " - f"Choose from: {list(VECTORINDEX_REGISTRY)}") - return VECTORINDEX_REGISTRY[backend](**kwargs) + embedder: Embedder + @property + @abstractmethod + def collections(self) -> list[str]: + """Names of the collections this store hosts.""" -@dataclass -class CollectionRoute: - """ - Describes which embedding client and vector-index backend to use for a - named collection. - - Parameters - ---------- - embedding_backend : str - Key in EMBEDDER_REGISTRY, e.g. ``"local"`` or ``"api"``. - vector_db : str - Key in VECTORINDEX_REGISTRY, e.g. ``"faiss"`` or ``"chroma"``. - embedder_kwargs : dict - Extra kwargs forwarded to the embedding client constructor. - index_kwargs : dict - Extra kwargs forwarded to the vector-index constructor. - description : str - Human-readable note shown in ``list_collections()`` when no - DESCRIPTION.md file exists. - hybrid : bool - If True (default), maintain a BM25 sparse index alongside the - dense vector index and fuse results via Reciprocal Rank Fusion - on every search. Costs one pickle file per collection and a - rebuild on every write; gains are exact-token recall (API - names, error strings, identifiers) that cosine on dense - embeddings tends to under-rank. Set False to skip BM25 entirely - for a collection. - """ + @abstractmethod + def has_collection(self, name: str) -> bool: + """Whether *name* is hosted by this store.""" - embedding_backend: str = "api" - vector_db: str = "chroma" - embedder_kwargs: dict = field(default_factory=dict) - index_kwargs: dict = field(default_factory=dict) - description: str = "" - hybrid: bool = True + @abstractmethod + def collection_info(self, name: str) -> dict: + """Listing metadata for *name* (description, embedder, vector_db).""" - def to_dict(self) -> dict: - return asdict(self) + @abstractmethod + def embed(self, texts: list[str]) -> np.ndarray: + """Embed *texts* with this store's embedder (L2-normalized).""" - @classmethod - def from_dict(cls, d: dict) -> "CollectionRoute": - return cls(**d) + @abstractmethod + def add_chunks( + self, + collection: str, + chunks: list[dict], + return_embeddings: bool = False, + ) -> dict: + """Embed + store pre-built ``{text, metadata}`` chunks into *collection*.""" + @abstractmethod + def add_entries( + self, + texts: list[str], + collection: str, + metadatas: list[dict] | None = None, + return_embeddings: bool = False, + ) -> dict: + """Embed + store raw *texts* (with optional metadata) into *collection*.""" -class KnowledgeBase: - """ - Collection-based document retrieval with pluggable embedder & vector-DB routing. + @abstractmethod + def search( + self, query: str, collection: str, top_k: int, where: dict | None = None + ) -> list[dict]: + """Single-collection hybrid search → ``[{chunk, score}, ...]``.""" - Each collection lives in ``//`` and may use a - *different* embedding model and vector-index backend. Routing is controlled - by a dictionary of :class:`CollectionRoute` objects keyed on collection name. + def close(self) -> None: + pass - Quick-start - ----------- - .. code-block:: python - from knowledge_base import KnowledgeBase, CollectionRoute - - kb = KnowledgeBase( - index_dir="./kb_store", - routes={ - # GPU-heavy research docs → local model + FAISS - "research": CollectionRoute( - embedding_backend="local", - vector_db="faiss", - embedder_kwargs={"model": "BAAI/bge-base-en-v1.5"}, - ), - # Fast live ingestion → API + Chroma - "support": CollectionRoute( - embedding_backend="api", - vector_db="chroma", - embedder_kwargs={"model": "text-embedding-3-small"}, - ), - }, - ) +class ChromaVectorStore(VectorStore): + """Local-ChromaDB store: one embedder, many collections under *index_dir*. - kb.ingest("./docs/research", "research") - results = kb.search("transformer architecture", "research") + Each collection lives in ``//`` as a Chroma persistent + collection (dense), a ``chunks.jsonl`` payload, and a ``bm25.pkl`` sparse + leg. The embedder is fixed for the whole store; it is built lazily (via + :meth:`Embedder.create`) so server startup can background-load it, or injected + directly for reuse across stores. - Embedding backends - ------------------ - ``"local"`` - sentence-transformers, no network required. - ``"api"`` - OpenAI-compatible REST API. Reads ``LLM_API_KEY`` env var. - - Vector-index backends - --------------------- - ``"chroma"`` - ChromaDB HNSW index (default). Supports metadata filtering - and incremental updates. - ``"faiss"`` - Flat inner-product index. Faster for small static collections - but no metadata filtering. + The local store is **unconditionally hybrid**: it writes a BM25 sparse leg on + every write and fuses dense + sparse on every (unfiltered) search. Dense-only + retrieval is not a toggle here — a metadata-``where`` filter falls back to + dense-only for that one query (BM25 has no filter equivalent), and dense-only + *stores* (external/BYO adapters with no local sparse leg) are a separate store + type, not a flag on this one. """ - FILE_TYPES = [ - "pdf", "md", "rst", "txt", "py", "docx", - "json", "yaml", "yml", - # Packaging metadata: agents reading this index need to know which - # version of a library to install when registering tools that - # depend on it. pyproject.toml is the modern standard; setup.cfg - # is still common in older codebases. - "toml", "cfg", - ] - _ROUTE_FILE = "route.json" - def __init__( self, index_dir: str | Path, - chunk_size: int = 1024, - chunk_overlap: int = 128, - rerank_model: str = "BAAI/bge-reranker-v2-m3", - default_rerank: bool = False, - # Global default route (used when collection has no specific route) - default_embedder: str | None = None, - default_index: str | None = None, - embedder_kwargs: dict | None = None, - # Per-collection routing registry - routes: dict[str, CollectionRoute] | None = None, + embedder: Embedder | None = None, + *, + backend: str = "api", + model: str | None = None, + base_url: str | None = None, + api_key: str | None = None, + device: str | None = None, ): self.index_dir = Path(index_dir) - self.chunk_size = chunk_size - self.chunk_overlap = chunk_overlap - self.rerank_model = rerank_model - self.default_rerank = default_rerank - self.index_dir.mkdir(parents=True, exist_ok=True) - # Build default route from explicit kwargs (config-driven). - # No env var reads — callers pass config values through. - self._default_route = CollectionRoute( - embedding_backend=default_embedder or "api", - vector_db=default_index or "chroma", - embedder_kwargs=embedder_kwargs or {}, - ) - - # Per-collection route registry - self._routes: dict[str, CollectionRoute] = routes or {} - - # Shared embedder cache: embedder_key → client instance - # Key is "|" so identical configs share one client. - self._embedder_cache: dict[str, BaseEmbeddingClient] = {} - # Serializes embedder construction so a background preload (see - # ``preload_default_embedder``) and a foreground first-query call - # don't race and double-load the model. Construction is rare; - # the lock isn't a hot-path concern. - import threading as _threading - self._embedder_lock = _threading.Lock() + # Embedder is either injected (shareable across stores) or built lazily + # from explicit args. Lazy build keeps the ~5-10s sentence-transformers + # load off the hot path (see preload via KnowledgeBase). backend/model + # are kept as lightweight labels so listing never forces a model load. + self._embedder = embedder + if embedder is not None: + self._backend, self._model = embedder.backend, embedder.model + else: + self._backend, self._model = backend, model + self._base_url, self._api_key, self._device = base_url, api_key, device + # Serializes embedder construction so a background preload and a + # foreground first-query call don't race and double-load the model. + self._embedder_lock = threading.Lock() + + # Collection runtime caches: name → (ChromaIndex, chunks) and name → BM25. + self._cache: dict[str, tuple[ChromaIndex, list[dict]]] = {} + self._bm25_cache: dict[str, BM25Index] = {} - # Collection runtime cache: name → (BaseVectorIndex, list[dict]) - self._cache: dict[str, tuple[BaseVectorIndex, list[dict]]] = {} + # -- embedder ------------------------------------------------------------ - # BM25 sparse-index cache for hybrid-enabled collections. Loaded - # from disk on first search, rebuilt and resaved on every write. - self._bm25_cache: dict[str, BM25Index] = {} + @property + def embedder(self) -> Embedder: # type: ignore[override] + with self._embedder_lock: + if self._embedder is None: + self._embedder = Embedder.create( + self._backend, + model=self._model, + base_url=self._base_url, + api_key=self._api_key, + device=self._device, + ) + return self._embedder - # Per-file-type parser cache. Constructing a CodeSplitter loads the - # tree-sitter language definition (~25ms each), so a 300-file ingest - # without this cache pays 7-8 seconds in parser construction alone. - # Parsers are stateless once built; safe to share across files. - self._parsers: dict[str, Any] = {} + @property + def backend(self) -> str: + return self._backend - # Per-ingest counter for files that _chunk_file skipped due to - # read/parse errors. ingest()/append() reset this before the loop - # and surface it in the result dict so users notice when a non-zero - # number of files are silently being dropped. - self._chunk_skip_count: int = 0 + @property + def model(self) -> str | None: + return self._model - self._reranker = None + def embed(self, texts: list[str]) -> np.ndarray: + with kb_embed_span(self.backend, self.model, len(texts)): + return self._normalize(self.embedder.embed(texts)) @staticmethod - def _stale_index_message(collection: str, exc: Exception) -> str | None: - """Return a user-friendly hint when *exc* looks like a dim mismatch. + def _normalize(arr: np.ndarray) -> np.ndarray: + norms = np.linalg.norm(arr, axis=-1, keepdims=True) + return arr / np.where(norms > 0, norms, 1) - Chroma raises ``InvalidDimensionException`` and FAISS raises - ``RuntimeError``/``AssertionError`` with "dimension" in the - message when a query vector's dimension doesn't match the index's. - That happens when the embedder used to BUILD the index has been - swapped for one with a different output dim — typically because - the user changed ``embedding.backend`` or ``embedding.model`` in - their config after init. Detect on message-shape (no chroma - import here) and return the recovery instructions. - """ - msg = str(exc).lower() - if "dimension" in msg or "dim mismatch" in msg or "shape" in msg: - return ( - f"Embedding dimension mismatch in collection {collection!r}. " - f"The index was built with a different embedder than the one " - f"currently configured. Run `dsagt setup-kb` with the active " - f"embedding settings, then drop the stale collection: " - f"`rm -rf /kb_index/{collection}` and re-init/start " - f"the project." - ) - return None + # -- collection inventory ------------------------------------------------ - # route management + @property + def collections(self) -> list[str]: + return [ + p.name + for p in self.index_dir.iterdir() + if p.is_dir() and (p / "chroma_ids.json").exists() + ] - def register_route(self, collection: str, route: CollectionRoute) -> None: - """Register (or update) a routing rule for *collection*. + def has_collection(self, name: str) -> bool: + return (self.index_dir / name / "chroma_ids.json").exists() - Updates the in-memory routing table AND persists ``route.json`` - to disk so the mapping survives server restarts. - """ - self._routes[collection] = route - coll_dir = self.index_dir / collection - coll_dir.mkdir(exist_ok=True) - (coll_dir / self._ROUTE_FILE).write_text(json.dumps(route.to_dict(), indent=2)) - logger.info("Registered route for '%s': embedder=%s index=%s", - collection, route.embedding_backend, route.vector_db) + def collection_info(self, name: str) -> dict: + coll_dir = self.index_dir / name + desc_path = coll_dir / "DESCRIPTION.md" + description = desc_path.read_text() if desc_path.exists() else "" + return { + "name": name, + "description": description, + "embedding_backend": self.backend, + "embedding_model": self.model or "default", + "vector_db": "chroma", + } - def _get_route(self, collection: str) -> CollectionRoute: - return self._routes.get(collection, self._default_route) + # -- writes -------------------------------------------------------------- - def _get_embedder(self, route: CollectionRoute) -> BaseEmbeddingClient: - """Return (possibly cached) embedder matching *route*. + def add_chunks( + self, + collection: str, + chunks: list[dict], + return_embeddings: bool = False, + ) -> dict: + """Embed + store pre-built ``{text, metadata}`` chunks. - Routes carry per-collection overrides (e.g. a specific model name); - credentials (api_key, base_url) live on the kb's default route. - Merge so explicit routes inherit defaults unless they override. + The shared write path for both document ingest (chunks carry + ``source_file`` / ``file_type``) and structured entries. Rebuilds the + BM25 sparse leg on every write (the store is unconditionally hybrid). """ - merged = {**self._default_route.embedder_kwargs, **route.embedder_kwargs} - key = f"{route.embedding_backend}|{sorted(merged.items())}" - with self._embedder_lock: - if key not in self._embedder_cache: - self._embedder_cache[key] = _make_embedder( - route.embedding_backend, **merged - ) - return self._embedder_cache[key] + coll_dir = self.index_dir / collection + coll_dir.mkdir(parents=True, exist_ok=True) - def preload_default_embedder(self) -> None: - """Kick off embedder construction in a daemon thread. - - Called at MCP server startup so the heavy load — sentence- - transformers import, ``SentenceTransformer(...)`` model load, - etc., ~5–10s for the default local backend — happens in - parallel with the rest of server bootstrap. By the time the - agent makes its first kb call, the embedder is cached and the - call completes immediately instead of paying the load cost. - - Failure inside the thread is swallowed: the model-load error - will surface again (with full traceback) on the first real - embedding call, where the agent can surface it usefully. The - background thread shouldn't crash the server before the agent - even gets a chance to query. - """ - import threading + texts = [c["text"] for c in chunks] + metadatas = [c.get("metadata", {}) for c in chunks] + embeddings = self.embed(texts) - def _load() -> None: - try: - self._get_embedder(self._default_route) - except Exception as e: - logger.warning("Background embedder preload failed: %s", e) + if (coll_dir / "chunks.jsonl").exists(): + index, existing_chunks = self._load(collection) + else: + index = ChromaIndex(collection_name=coll_dir.name, persist_dir=coll_dir) + existing_chunks = [] - threading.Thread( - target=_load, name="dsagt-embedder-preload", daemon=True, - ).start() + try: + index.add(embeddings, metadatas=metadatas) + except Exception as e: + hint = self._stale_index_message(collection, e) + if hint: + raise RuntimeError(hint) from e + raise + index.save(coll_dir) - @property - def collections(self) -> list[str]: - return [p.name for p in self.index_dir.iterdir() if p.is_dir() and - ((p / "index.faiss").exists() or (p / "chroma_ids.json").exists())] + with open(coll_dir / "chunks.jsonl", "a") as f: + for chunk in chunks: + f.write(json.dumps(chunk) + "\n") - def list_collections(self) -> list[dict]: - result = [] - for name in self.collections: - coll_dir = self.index_dir / name - desc_path = coll_dir / "DESCRIPTION.md" - - # Ensure the persisted route is loaded into _routes so we always - # report the actual model/backend, not the server default. - if name not in self._routes: - persisted = self._load_route(name) - if persisted: - self._routes[name] = persisted - - route = self._routes.get(name, self._default_route) - description = ( - desc_path.read_text() if desc_path.exists() - else route.description - ) - result.append({ - "name": name, - "description": description, - "embedding_backend": route.embedding_backend, - "embedding_model": route.embedder_kwargs.get("model", "default"), - "vector_db": route.vector_db, - }) + all_chunks = existing_chunks + chunks + self._rebuild_bm25(collection, all_chunks) + self._cache[collection] = (index, all_chunks) + + result = { + "collection": collection, + "entries_added": len(chunks), + "total_entries": len(all_chunks), + } + if return_embeddings: + result["embeddings"] = embeddings return result - @traced( - "kb.ingest", - capture=["collection_name"], - extract_return={ - "n_files": lambda r: r.get("files"), - "n_chunks": lambda r: r.get("chunks"), - }, - ) - def ingest( + def add_entries( self, - folder: str | Path, - collection_name: str | None = None, - file_types: list[str] | None = None, - route: CollectionRoute | None = None, - exclude_patterns: list[str] | None = None, + texts: list[str], + collection: str, + metadatas: list[dict] | None = None, + return_embeddings: bool = False, ) -> dict: - """ - Ingest *folder* as a new collection. - - Parameters - ---------- - folder : path - Source directory. - collection_name : str, optional - Defaults to folder name. - file_types : list[str], optional - Extensions to include (without leading dot). - route : CollectionRoute, optional - Override routing for this collection. Persisted to disk so - subsequent ``search`` / ``append`` calls use the same backend. - exclude_patterns : list[str], optional - Glob-style patterns matched (via :func:`fnmatch.fnmatch`) - against each file's path *relative to* ``folder``. Any file - whose relative path matches any pattern is skipped. Patterns - are checked against both the full relative path and the - basename, so ``"tests/*"`` excludes a top-level tests dir and - ``"test_*.py"`` excludes test files anywhere in the tree. - Useful for skipping tests, build artifacts, and private modules - without inflating the embed cost of large libraries. - """ - folder = Path(folder) - collection = collection_name or folder.name - file_types = file_types or self.FILE_TYPES - - obs.set_inputs({ - "folder": str(folder), - "collection": collection, - "file_types": file_types, - }) + """Add raw *texts* (no parsing/chunking) as entries in *collection*.""" + coll_dir = self.index_dir / collection + existing = 0 + if (coll_dir / "chunks.jsonl").exists(): + _, existing_chunks = self._load(collection) + existing = len(existing_chunks) - # Set route in memory so _get_embedder resolves during the build. - # Persisted to disk via register_route after the index is built. - if route is not None: - self._routes[collection] = route - active_route = self._get_route(collection) + chunks = [] + for i, text in enumerate(texts): + entry_meta = metadatas[i] if metadatas else {} + chunks.append( + { + "text": text, + "metadata": { + "collection": collection, + "source_file": "entry", + "chunk_index": existing + i, + **entry_meta, + }, + } + ) + return self.add_chunks(collection, chunks, return_embeddings=return_embeddings) - coll_dir = self.index_dir / collection - coll_dir.mkdir(exist_ok=True) + # -- single-collection hybrid search ------------------------------------ + + def search( + self, query: str, collection: str, top_k: int, where: dict | None = None + ) -> list[dict]: + index, chunks = self._load(collection) + query_emb = self.embed([query])[0] + + # ``where`` (metadata filter) disables the BM25 leg — BM25 has no + # metadata-filter equivalent, so a filtered search is dense-only. + filtered = where is not None + do_hybrid = not filtered + # Oversample the per-ranker pools so RRF has depth on small top_k. + candidate_k = min( + max(top_k * 10, 50) if do_hybrid else top_k, + len(chunks), + ) + + with kb_index_search_span("chroma", candidate_k, filtered): + try: + if filtered: + dense_scores, dense_indices = index.search( + query_emb, candidate_k, where=where + ) + else: + dense_scores, dense_indices = index.search(query_emb, candidate_k) + except Exception as e: + hint = self._stale_index_message(collection, e) + if hint: + raise RuntimeError(hint) from e + raise + + if do_hybrid: + bm25 = self._get_bm25(collection) + _, sparse_indices = bm25.search(query, candidate_k) + dense_ranking = [int(i) for i in dense_indices if i >= 0] + sparse_ranking = [int(i) for i in sparse_indices] + merged = _rrf_merge([dense_ranking, sparse_ranking]) + results = [ + {"chunk": chunks[idx], "score": float(score)} for idx, score in merged + ] + else: + results = [ + {"chunk": chunks[i], "score": float(dense_scores[j])} + for j, i in enumerate(dense_indices) + if i >= 0 + ] + return results[:top_k] + + # -- persistence helpers ------------------------------------------------- + + def _rebuild_bm25(self, collection: str, chunks: list[dict]) -> None: + """Build (or rebuild) the BM25 index for *collection* from *chunks*.""" + bm25 = BM25Index() + bm25.build([c["text"] for c in chunks]) + bm25.save(self.index_dir / collection) + self._bm25_cache[collection] = bm25 + + def _get_bm25(self, collection: str) -> BM25Index: + cached = self._bm25_cache.get(collection) + if cached is not None: + return cached + bm25 = BM25Index.load(self.index_dir / collection) + self._bm25_cache[collection] = bm25 + return bm25 + + def _load(self, name: str) -> tuple[ChromaIndex, list[dict]]: + """Load a collection's index + chunks (cached in memory).""" + if name in self._cache: + return self._cache[name] + + coll_dir = self.index_dir / name + if not coll_dir.exists(): + raise ValueError(f"Collection '{name}' not found") + + index = ChromaIndex.load(coll_dir) + with open(coll_dir / "chunks.jsonl") as f: + chunks = [json.loads(line) for line in f] + + self._cache[name] = (index, chunks) + return index, chunks + + @staticmethod + def _stale_index_message(collection: str, exc: Exception) -> str | None: + """Return a user-friendly hint when *exc* looks like a dim mismatch. + + Chroma raises ``InvalidDimensionException`` with "dimension" in the + message when a query vector's dimension doesn't match the index's — + i.e. the embedder that BUILT the index was swapped for one with a + different output dim (the user changed ``embedding.backend`` / + ``embedding.model`` after init). Detect on message-shape. + """ + msg = str(exc).lower() + if "dimension" in msg or "dim mismatch" in msg or "shape" in msg: + return ( + f"Embedding dimension mismatch in collection {collection!r}. " + f"The index was built with a different embedder than the one " + f"currently configured. Drop the stale collection " + f"(`rm -rf /kb_index/{collection}`) and re-create the " + f"project with `dsagt init` so it rebuilds with the active " + f"embedding settings." + ) + return None + + def close(self) -> None: + if self._embedder is not None: + self._embedder.close() + self._embedder = None + self._cache.clear() + self._bm25_cache.clear() + + +# =========================================================================== +# KnowledgeBase — the federation infra: a list of stores + ingest pipeline +# =========================================================================== + + +class KnowledgeBase: + """Collection-based document retrieval over one-or-more vector stores. + + Holds a **list** of :class:`VectorStore`s (today just the internal local + Chroma store) and its job is to **fuse their collections**: collection→store + routing plus rank-fusion across collections. It also owns the document + ingestion pipeline (collect / parse / chunk → ``VectorStore.add_chunks``) and + cross-collection reranking. This is the shared substrate every KB consumer + (retrieval, memory, provenance, skills) calls into. + + Quick-start + ----------- + .. code-block:: python + + kb = KnowledgeBase(index_dir="./kb_store", default_embedder="local") + kb.ingest("./docs/research", "research") + results = kb.search("transformer architecture", "research") + """ + + FILE_TYPES = [ + "pdf", + "md", + "rst", + "txt", + "py", + "docx", + "json", + "yaml", + "yml", + # Packaging metadata: agents reading this index need to know which + # version of a library to install when registering tools that + # depend on it. pyproject.toml is the modern standard; setup.cfg + # is still common in older codebases. + "toml", + "cfg", + ] + + def __init__( + self, + index_dir: str | Path, + chunk_size: int = 1024, + chunk_overlap: int = 128, + rerank_model: str = "BAAI/bge-reranker-v2-m3", + default_rerank: bool = False, + recency_half_life_days: float | None = None, + # Internal store's embedder (one per store, fixed at construction). + # Explicit args — callers unpack their config here, no kwargs dict. + default_embedder: str | None = None, + model: str | None = None, + base_url: str | None = None, + api_key: str | None = None, + device: str | None = None, + ): + self.index_dir = Path(index_dir) + self.chunk_size = chunk_size + self.chunk_overlap = chunk_overlap + self.rerank_model = rerank_model + self.default_rerank = default_rerank + # Episodic recency weighting (session_memory only); None = off. + self._recency_half_life_days = recency_half_life_days + + self.index_dir.mkdir(parents=True, exist_ok=True) + + # The internal store: local Chroma, one embedder, many collections. + # External BYO stores would be appended to ``self._stores`` (deferred). + self._store = ChromaVectorStore( + self.index_dir, + backend=default_embedder or "api", + model=model, + base_url=base_url, + api_key=api_key, + device=device, + ) + self._stores: list[VectorStore] = [self._store] + + # Per-file-type parser cache. Constructing a CodeSplitter loads the + # tree-sitter language definition (~25ms each), so a 300-file ingest + # without this cache pays 7-8 seconds in parser construction alone. + # Parsers are stateless once built; safe to share across files. + self._parsers: dict[str, Any] = {} + + # Per-ingest counter for files that _chunk_file skipped due to + # read/parse errors. ingest()/append() reset this before the loop + # and surface it in the result dict so users notice when a non-zero + # number of files are silently being dropped. + self._chunk_skip_count: int = 0 + + self._reranker = None + + # -- store routing ------------------------------------------------------- + + def _store_for(self, collection: str) -> VectorStore: + """Return the store hosting *collection*, else raise ``ValueError``.""" + for store in self._stores: + if store.has_collection(collection): + return store + raise ValueError(f"Collection '{collection}' not found") + + @property + def collections(self) -> list[str]: + """Union of collection names across all stores.""" + seen: list[str] = [] + for store in self._stores: + for name in store.collections: + if name not in seen: + seen.append(name) + return seen + + def list_collections(self) -> list[dict]: + return [ + store.collection_info(name) + for store in self._stores + for name in store.collections + ] + + def preload_default_embedder(self) -> None: + """Kick off internal-store embedder construction in a daemon thread. + + Called at MCP server startup so the heavy load (sentence-transformers + import + model load, ~5-10s) happens in parallel with the rest of + bootstrap. Failure is swallowed: it resurfaces with a full traceback + on the first real embedding call. + """ + + def _load() -> None: + try: + self._store.embedder # noqa: B018 — triggers lazy construction + except Exception as e: + logger.warning("Background embedder preload failed: %s", e) + + threading.Thread( + target=_load, name="dsagt-embedder-preload", daemon=True + ).start() + + def embed_texts( + self, texts: list[str], collection: str | None = None + ) -> np.ndarray: + """Embed *texts* with the internal store's embedder (L2-normalized).""" + return self._store.embed(texts) + + # -- structured entries (delegate to internal store) -------------------- + + @traced( + "kb.add_entries", + capture=["collection"], + extract_return={"n_entries": lambda r: r.get("entries_added")}, + ) + def add_entries( + self, + texts: list[str], + collection: str, + metadatas: list[dict] | None = None, + return_embeddings: bool = False, + ) -> dict: + """Add pre-formed text entries with optional metadata to a collection. + + Unlike ``ingest``/``append``, this skips document parsing and chunking. + Used by episodic memory, tool_executions, and other structured entry + types that produce their own text representations. Metadata is stored + as native Chroma metadata for ``where`` filtering. + """ + obs.set_inputs( + { + "collection": collection, + "n_entries": len(texts), + "texts_preview": [t[:200] for t in texts[:3]], + } + ) + return self._store.add_entries( + texts, + collection, + metadatas=metadatas, + return_embeddings=return_embeddings, + ) + + # -- document ingestion pipeline ---------------------------------------- + + @traced( + "kb.ingest", + capture=["collection_name"], + extract_return={ + "n_files": lambda r: r.get("files"), + "n_chunks": lambda r: r.get("chunks"), + }, + ) + def ingest( + self, + folder: str | Path, + collection_name: str | None = None, + file_types: list[str] | None = None, + exclude_patterns: list[str] | None = None, + ) -> dict: + """Ingest *folder* as a new collection in the internal store.""" + folder = Path(folder) + collection = collection_name or folder.name + file_types = file_types or self.FILE_TYPES + + obs.set_inputs( + { + "folder": str(folder), + "collection": collection, + "file_types": file_types, + } + ) + + coll_dir = self.index_dir / collection + coll_dir.mkdir(parents=True, exist_ok=True) # Record source folder so the MCP server can detect re-ingests vs. conflicts. (coll_dir / "source.txt").write_text(str(folder.resolve())) @@ -1046,16 +1375,10 @@ def ingest( files = self._collect_files(folder, file_types, exclude_patterns) logger.info("Found %d files to process", len(files)) - # Reset the per-ingest skip counter; _chunk_file bumps it on - # read/parse failure. We surface the result in the return dict - # so callers (and the user looking at dsagt-setup-kb output) - # notice if a non-trivial number of files were silently dropped. self._chunk_skip_count = 0 chunks = [chunk for f in files for chunk in self._chunk_file(f, collection)] n_skipped = self._chunk_skip_count - logger.info( - "Created %d chunks (skipped %d files)", len(chunks), n_skipped, - ) + logger.info("Created %d chunks (skipped %d files)", len(chunks), n_skipped) if not chunks: return { @@ -1065,28 +1388,7 @@ def ingest( "skipped_files": n_skipped, } - embedder = self._get_embedder(active_route) - with kb_embed_span(active_route.embedding_backend, - active_route.embedder_kwargs.get("model"), len(chunks)): - embeddings = self._normalize(embedder.embed([c["text"] for c in chunks])) - - index = _make_index(active_route.vector_db, - **self._index_init_kwargs(active_route, coll_dir)) - index.add(embeddings) - index.save(coll_dir) - - # Persist route AFTER index is built — guarantees route.json always - # reflects what was actually used, never overwritten by a racing job. - self.register_route(collection, active_route) - - with open(coll_dir / "chunks.jsonl", "w") as f: - for chunk in chunks: - f.write(json.dumps(chunk) + "\n") - - if active_route.hybrid: - self._rebuild_bm25(collection, chunks) - - self._cache[collection] = (index, chunks) + self._store.add_chunks(collection, chunks) return { "collection": collection, "files": len(files), @@ -1111,15 +1413,15 @@ def append( """Append documents to an existing collection.""" file_types = file_types or self.FILE_TYPES - obs.set_inputs({ - "collection": collection, - "n_paths": len(paths), - "paths_preview": [str(p) for p in paths[:5]], - }) + obs.set_inputs( + { + "collection": collection, + "n_paths": len(paths), + "paths_preview": [str(p) for p in paths[:5]], + } + ) - index, existing_chunks = self._load(collection) - active_route = self._get_route(collection) - coll_dir = self.index_dir / collection + store = self._store_for(collection) # raises ValueError if missing files: list[Path] = [] for p in paths: @@ -1132,365 +1434,128 @@ def append( logger.warning("Path not found, skipping: %s", p) if not files: - return {"collection": collection, "files": 0, "chunks_added": 0, - "total_chunks": len(existing_chunks), "skipped_files": 0} + return { + "collection": collection, + "files": 0, + "chunks_added": 0, + "skipped_files": 0, + } - # Reset and capture per-file skip count, same pattern as ingest(). self._chunk_skip_count = 0 new_chunks = [chunk for f in files for chunk in self._chunk_file(f, collection)] n_skipped = self._chunk_skip_count if not new_chunks: - return {"collection": collection, "files": len(files), - "chunks_added": 0, "total_chunks": len(existing_chunks), - "skipped_files": n_skipped} - - embedder = self._get_embedder(active_route) - with kb_embed_span(active_route.embedding_backend, - active_route.embedder_kwargs.get("model"), len(new_chunks)): - embeddings = self._normalize(embedder.embed([c["text"] for c in new_chunks])) - index.add(embeddings) - index.save(coll_dir) - - with open(coll_dir / "chunks.jsonl", "a") as f: - for chunk in new_chunks: - f.write(json.dumps(chunk) + "\n") + return { + "collection": collection, + "files": len(files), + "chunks_added": 0, + "skipped_files": n_skipped, + } - all_chunks = existing_chunks + new_chunks - if active_route.hybrid: - self._rebuild_bm25(collection, all_chunks) - self._cache[collection] = (index, all_chunks) + result = store.add_chunks(collection, new_chunks) return { "collection": collection, "files": len(files), "chunks_added": len(new_chunks), - "total_chunks": len(all_chunks), + "total_chunks": result.get("total_entries"), "skipped_files": n_skipped, } - @traced( - "kb.add_entries", - capture=["collection"], - extract_return={"n_entries": lambda r: r.get("entries_added")}, - ) - def add_entries( - self, - texts: list[str], - collection: str, - metadatas: list[dict] | None = None, - route: CollectionRoute | None = None, - return_embeddings: bool = False, - ) -> dict: - """Add pre-formed text entries with optional metadata to a collection. - - Unlike ``ingest``/``append``, this skips document parsing and chunking. - Each text is embedded and stored as-is. Used by episodic memory, - tool_executions, and other structured entry types that produce their - own text representations rather than ingesting raw documents. - - Parameters - ---------- - texts : list[str] - Text content for each entry (will be embedded). - collection : str - Target collection name (created if it doesn't exist). - metadatas : list[dict], optional - Per-entry metadata dicts. On ChromaDB-backed collections these - are stored as native metadata for ``where`` filtering. On all - backends they are merged into the chunk metadata in chunks.jsonl. - route : CollectionRoute, optional - Override routing for this collection (persisted to disk). - return_embeddings : bool, optional - If True, include the freshly-computed embeddings in the result - dict under the ``"embeddings"`` key. Callers that need the - embeddings for downstream work (e.g. centroid-based outlier - detection in memory extraction) should set this so they can - avoid a second round-trip to the embedding API. - - Returns - ------- - dict - ``{collection, entries_added, total_entries}`` plus - ``"embeddings"`` (numpy ndarray) if ``return_embeddings=True``. - """ - obs.set_inputs({ - "collection": collection, - "n_entries": len(texts), - "texts_preview": [t[:200] for t in texts[:3]], - }) - - if not texts: - result = {"collection": collection, "entries_added": 0, "total_entries": 0} - if return_embeddings: - result["embeddings"] = np.array([], dtype=np.float32) - return result - - coll_dir = self.index_dir / collection - coll_dir.mkdir(exist_ok=True) - - if route is not None: - self._routes[collection] = route - active_route = self._get_route(collection) - - embedder = self._get_embedder(active_route) - with kb_embed_span(active_route.embedding_backend, - active_route.embedder_kwargs.get("model"), len(texts)): - embeddings = self._normalize(embedder.embed(texts)) - - # Load existing or create new index - if (coll_dir / "chunks.jsonl").exists(): - index, existing_chunks = self._load(collection) - else: - index = _make_index( - active_route.vector_db, - **self._index_init_kwargs(active_route, coll_dir), - ) - existing_chunks = [] - - # Build chunk dicts (consistent with chunks.jsonl format) - new_chunks = [] - for i, text in enumerate(texts): - entry_meta = metadatas[i] if metadatas else {} - chunk = { - "text": text, - "metadata": { - "collection": collection, - "source_file": "entry", - "chunk_index": len(existing_chunks) + i, - **entry_meta, - }, - } - new_chunks.append(chunk) - - # Store embeddings (with metadata on ChromaDB) - try: - if active_route.vector_db == "chroma" and metadatas is not None: - index.add(embeddings, metadatas=metadatas) - else: - index.add(embeddings) - except Exception as e: - hint = self._stale_index_message(collection, e) - if hint: - raise RuntimeError(hint) from e - raise - - index.save(coll_dir) - self.register_route(collection, active_route) - - # Append to chunks.jsonl - with open(coll_dir / "chunks.jsonl", "a") as f: - for chunk in new_chunks: - f.write(json.dumps(chunk) + "\n") - - all_chunks = existing_chunks + new_chunks - if active_route.hybrid: - self._rebuild_bm25(collection, all_chunks) - self._cache[collection] = (index, all_chunks) - - result = { - "collection": collection, - "entries_added": len(texts), - "total_entries": len(all_chunks), - } - if return_embeddings: - result["embeddings"] = embeddings - return result - - def embed_texts(self, texts: list[str], collection: str) -> np.ndarray: - """Embed texts using the embedder configured for a collection. - - Returns L2-normalized float32 array of shape (n_texts, dim). - """ - route = self._get_route(collection) - embedder = self._get_embedder(route) - return self._normalize(embedder.embed(texts)) + # -- federated search ---------------------------------------------------- @traced("kb.search", capture=["collection", "top_k", "rerank"]) def search( self, query: str, - collection: str, + collection: str | None = None, + collections: list[str] | None = None, top_k: int = 5, rerank: bool | None = None, where: dict | None = None, ) -> list[dict]: - """Search *collection* with *query*. - - Parameters - ---------- - rerank : bool, optional - Use cross-encoder reranking. Defaults to ``self.default_rerank`` - (set from ``knowledge.rerank`` in dsagt_config.yaml). - where : dict, optional - ChromaDB ``where`` filter clause. Only effective on ChromaDB-backed - collections; silently ignored for FAISS collections. When set, - disables the BM25 sparse leg of hybrid search (BM25 has no - metadata-filter equivalent), so the result is dense-only. + """Search one or many collections, fusing across them by rank. + + A single collection routes straight to its store's hybrid search. + Multiple collections fan out — each store searched, then the per- + collection rankings fused by Reciprocal Rank Fusion (rank-only, so + different embedding spaces compose correctly). Optional cross-encoder + rerank runs over the fused candidates. + + Missing collections are skipped with a warning; the search fails only + when *every* requested collection is absent. """ if rerank is None: rerank = self.default_rerank - obs.set_inputs({"query": query, "collection": collection, "top_k": top_k}) + targets = collections or ([collection] if collection else []) + if not targets: + raise ValueError("Provide 'collection' or 'collections'") - index, chunks = self._load(collection) - active_route = self._get_route(collection) - embedder = self._get_embedder(active_route) - - with kb_embed_span(active_route.embedding_backend, - active_route.embedder_kwargs.get("model"), 1): - query_emb = self._normalize(embedder.embed([query]))[0] - - # Wider candidate pool when reranking (cross-encoder reorders top-N) - # OR when hybrid (RRF fuses two ranked lists; needs candidates from - # each ranker before merging). Floor of 50 keeps RRF meaningful on - # small top_k. - filtered = where is not None and active_route.vector_db == "chroma" - do_hybrid = active_route.hybrid and not filtered - oversample = (rerank or do_hybrid) - candidate_k = min( - max(top_k * 10, 50) if oversample else top_k, - len(chunks), + obs.set_inputs({"query": query, "collections": targets, "top_k": top_k}) + + # Oversample per-collection pools when reranking, fusing >1 collection, + # or recency-weighting (so a recent fact can be lifted from deep in the + # pool, not merely reordered within an already-cut top_k). + recency_target = bool( + self._recency_half_life_days and targets == [_RECENCY_COLLECTION] ) + oversample = rerank or len(targets) > 1 or recency_target + candidate_k = max(top_k * 10, 50) if oversample else top_k - with kb_index_search_span(active_route.vector_db, candidate_k, filtered): + per_coll: list[list[dict]] = [] + errors: list[str] = [] + for coll in targets: try: - if filtered: - dense_scores, dense_indices = index.search( - query_emb, candidate_k, where=where, - ) - else: - dense_scores, dense_indices = index.search( - query_emb, candidate_k, - ) - except Exception as e: - hint = self._stale_index_message(collection, e) - if hint: - raise RuntimeError(hint) from e - raise + store = self._store_for(coll) + per_coll.append(store.search(query, coll, candidate_k, where=where)) + except (ValueError, FileNotFoundError, KeyError) as e: + logger.warning("Search failed for '%s': %s", coll, e) + errors.append(str(e)) + + if not per_coll: + if len(targets) == 1: + raise ValueError( + errors[0] if errors else f"Collection '{targets[0]}' not found" + ) + raise ValueError(f"All collections failed: {'; '.join(errors)}") - if do_hybrid: - bm25 = self._get_bm25(collection) - _, sparse_indices = bm25.search(query, candidate_k) - dense_ranking = [int(i) for i in dense_indices if i >= 0] - sparse_ranking = [int(i) for i in sparse_indices] - merged = _rrf_merge([dense_ranking, sparse_ranking]) - results = [ - {"chunk": chunks[idx], "score": float(score)} - for idx, score in merged - ] - else: - results = [ - {"chunk": chunks[i], "score": float(dense_scores[j])} - for j, i in enumerate(dense_indices) if i >= 0 - ] + fused = per_coll[0] if len(per_coll) == 1 else _rrf_across(per_coll) - if rerank and results: - with kb_rerank_span(self.rerank_model, len(results)): - final = self._rerank(query, results, top_k) + # Episodic recency: a recent corrected fact outranks a stale one without + # any contradiction detection — recency is the ranker for session_memory + # (mutually exclusive with the cross-encoder; the two are alternative + # rerankers and recency is the one that matters for a time-ordered log). + if recency_target and fused: + final = _apply_recency(fused, self._recency_half_life_days, time.time())[ + :top_k + ] + elif rerank and fused: + with kb_rerank_span(self.rerank_model, len(fused)): + final = self._rerank(query, fused, top_k) else: - final = results[:top_k] + final = fused[:top_k] obs.set("hits", len(final)) - obs.set_outputs({ - "hits": len(final), - "top_texts": [r["chunk"].get("text", "")[:200] for r in final[:3]], - }) + obs.set_outputs( + { + "hits": len(final), + "top_texts": [r["chunk"].get("text", "")[:200] for r in final[:3]], + } + ) return final - def _rebuild_bm25(self, collection: str, chunks: list[dict]) -> None: - """Build (or rebuild) the BM25 index for *collection* from *chunks*. - - Called from every write path of a hybrid-enabled collection. BM25 - IDF stats are corpus-global, so there is no incremental update — - we always rebuild from the full chunk list. Cached on the KB - instance and persisted to ``/bm25.pkl``. - """ - bm25 = BM25Index() - bm25.build([c["text"] for c in chunks]) - bm25.save(self.index_dir / collection) - self._bm25_cache[collection] = bm25 - - def _get_bm25(self, collection: str) -> BM25Index: - """Return the cached BM25 index, loading from disk on first hit. - - Raises ``FileNotFoundError`` if the collection's route has - ``hybrid=True`` but no ``bm25.pkl`` exists on disk — i.e. the - collection was built by a pre-hybrid dsagt. Migration is - ``delete the collection and re-ingest``; we deliberately do NOT - rebuild lazily on first search because that would silently turn - the next search into an N-document tokenization run. - """ - cached = self._bm25_cache.get(collection) - if cached is not None: - return cached - coll_dir = self.index_dir / collection - bm25_path = coll_dir / BM25Index._FILENAME - if not bm25_path.exists(): - raise FileNotFoundError( - f"Collection '{collection}' has hybrid=True in its route but " - f"no bm25.pkl on disk. The collection was built before hybrid " - f"retrieval was added. To fix: delete {coll_dir} and re-ingest, " - f"or set hybrid=False in route.json." - ) - bm25 = BM25Index.load(coll_dir) - self._bm25_cache[collection] = bm25 - return bm25 - - @staticmethod - def _normalize(arr: np.ndarray) -> np.ndarray: - norms = np.linalg.norm(arr, axis=-1, keepdims=True) - return arr / np.where(norms > 0, norms, 1) - - def _index_init_kwargs(self, route: CollectionRoute, coll_dir: Path) -> dict: - """Extra kwargs needed by some index constructors at init time.""" - if route.vector_db == "chroma": - return {"collection_name": coll_dir.name, - "persist_dir": coll_dir, **route.index_kwargs} - return dict(route.index_kwargs) - - def _load_route(self, collection: str) -> CollectionRoute | None: - route_path = self.index_dir / collection / self._ROUTE_FILE - if route_path.exists(): - return CollectionRoute.from_dict(json.loads(route_path.read_text())) - return None - - def _load(self, name: str) -> tuple[BaseVectorIndex, list[dict]]: - """Load collection index + chunks (cached in memory).""" - if name in self._cache: - return self._cache[name] - - coll_dir = self.index_dir / name - if not coll_dir.exists(): - raise ValueError(f"Collection '{name}' not found") - - # Restore persisted route if not already registered - if name not in self._routes: - persisted = self._load_route(name) - if persisted: - self._routes[name] = persisted - - active_route = self._get_route(name) - - # Pick correct loader - if active_route.vector_db == "chroma": - index = ChromaIndex.load(coll_dir) - else: - index = FAISSIndex.load(coll_dir) - - with open(coll_dir / "chunks.jsonl") as f: - chunks = [json.loads(line) for line in f] - - self._cache[name] = (index, chunks) - return index, chunks - def _rerank(self, query: str, results: list[dict], top_k: int) -> list[dict]: if self._reranker is None: from sentence_transformers import CrossEncoder + self._reranker = CrossEncoder(self.rerank_model, max_length=512) pairs = [[query, r["chunk"]["text"]] for r in results] scores = self._reranker.predict(pairs, show_progress_bar=False) ranked = sorted(zip(results, scores), key=lambda x: x[1], reverse=True) return [{**r, "rerank_score": float(s)} for r, s in ranked[:top_k]] + # -- file discovery + chunking ------------------------------------------ + def _collect_files( self, folder: Path, @@ -1501,19 +1566,13 @@ def _collect_files( glob exclusions. Pure function relative to filesystem state — no caching, no - side-effecting attributes. Extracted from ``ingest()`` so file - discovery and exclusion logic can be tested in isolation without - spinning up an embedder, an index, or a chunker. - - Patterns are checked against the relative path, the basename, and - each individual path segment, so ``"tests"`` excludes any file - whose path contains a ``tests/`` directory regardless of depth. + side-effecting attributes. Patterns are checked against the relative + path, the basename, and each individual path segment, so ``"tests"`` + excludes any file whose path contains a ``tests/`` directory. """ from fnmatch import fnmatch - all_files = [ - f for ext in file_types for f in folder.glob(f"**/*.{ext}") - ] + all_files = [f for ext in file_types for f in folder.glob(f"**/*.{ext}")] if not exclude_patterns: return all_files @@ -1522,7 +1581,8 @@ def _excluded(f: Path) -> bool: rel = str(f.relative_to(folder)) name = f.name return any( - fnmatch(rel, pat) or fnmatch(name, pat) + fnmatch(rel, pat) + or fnmatch(name, pat) or any(fnmatch(part, pat) for part in Path(rel).parts) for pat in exclude_patterns ) @@ -1532,7 +1592,9 @@ def _excluded(f: Path) -> bool: if n_excluded: logger.info( "Excluded %d/%d files via %d pattern(s)", - n_excluded, len(all_files), len(exclude_patterns), + n_excluded, + len(all_files), + len(exclude_patterns), ) return kept @@ -1544,17 +1606,7 @@ def _chunk_file(self, path: Path, collection: str) -> Iterator[dict]: # Per-file read/parse failures are kept as soft failures (count # surfaced in ingest()'s return dict) rather than aborting the - # whole ingest, because real-world directories like cloned - # upstream repos contain occasional unreadable files (binary - # blobs misnamed .txt, malformed UTF-8) that shouldn't kill an - # ingest of thousands of files. Callers see the skip count - # and can investigate if it's suspiciously high. - # - # llama_index's SimpleDirectoryReader prints "Failed to load file - # ... Skipping..." directly to stdout (literal print(), not - # logger) for every file it can't parse — there's no level switch - # for this. Capture it: the file is already counted as a miss - # below, so the per-file print is pure noise. + # whole ingest try: with contextlib.redirect_stdout(_io.StringIO()): docs = SimpleDirectoryReader(input_files=[str(path)]).load_data() @@ -1568,7 +1620,9 @@ def _chunk_file(self, path: Path, collection: str) -> Iterator[dict]: parser = self._get_parser(file_type) try: nodes = parser.get_nodes_from_documents(docs) - except (ValueError, RuntimeError) as e: + except Exception as e: + # A single malformed file (or a tree-sitter ABI mismatch in the + # code parser) must not abort the whole ingest — skip it. logger.warning("Could not parse %s: %s", path, e) self._chunk_skip_count += 1 return @@ -1577,7 +1631,9 @@ def _chunk_file(self, path: Path, collection: str) -> Iterator[dict]: if not text: continue yield { - "id": hashlib.sha256(f"{path}:{i}:{text[:100]}".encode()).hexdigest()[:16], + "id": hashlib.sha256(f"{path}:{i}:{text[:100]}".encode()).hexdigest()[ + :16 + ], "text": text, "metadata": { "source_file": str(path), @@ -1588,13 +1644,7 @@ def _chunk_file(self, path: Path, collection: str) -> Iterator[dict]: } def _get_parser(self, file_type: str): - """Return a cached parser for *file_type*, building it on first use. - - Parsers are stateless after construction. ``CodeSplitter`` in - particular loads a tree-sitter language definition on every - construction (~25ms), so a large ingest used to pay this cost per - file; now it pays once per file type. - """ + """Return a cached parser for *file_type*, building it on first use.""" cached = self._parsers.get(file_type) if cached is not None: return cached @@ -1625,11 +1675,9 @@ def _get_parser(self, file_type: str): return parser def close(self) -> None: - for client in self._embedder_cache.values(): - client.close() - self._embedder_cache.clear() + for store in self._stores: + store.close() self._parsers.clear() - self._bm25_cache.clear() def __enter__(self): return self diff --git a/src/dsagt/mcp/knowledge_tools.py b/src/dsagt/mcp/knowledge_tools.py index 2ab2adc..d54895a 100644 --- a/src/dsagt/mcp/knowledge_tools.py +++ b/src/dsagt/mcp/knowledge_tools.py @@ -1,13 +1,18 @@ """MCP tools for knowledge-base retrieval. -Semantic search over document collections, background ingest/append jobs, and -registration of external vector stores. Long-running operations (ingest, -append) run in the background and return immediately with a ``job_id``; poll -``kb_job_status`` for completion. +Semantic search over document collections + background ingest/append jobs. +Long-running operations (ingest, append) run in the background and return +immediately with a ``job_id``; poll ``kb_job_status`` for completion. -Server configuration (chunk_size, vector_db, rerank) is read from the project's -dsagt_config.yaml. Embedding credentials flow through env vars (LLM_API_KEY, -OPENAI_BASE_URL, EMBEDDING_MODEL) set by ``dsagt start``. +Multi-collection search fans out and rank-fuses *below* this tool boundary, in +:meth:`dsagt.knowledge.KnowledgeBase.search` — the agent just names collection(s). +BYO external vector stores are deferred: ``kb_add_vector_db`` is intentionally +**not** registered (an external store is a ``VectorStore`` subclass appended to +the KB's store list, not a tool the agent calls). + +Server configuration (chunk_size, rerank) is read from the project's +dsagt_config.yaml. Embedding credentials flow through env vars (EMBEDDING_API_KEY, +EMBEDDING_BASE_URL, EMBEDDING_MODEL) set by ``dsagt start``. These definitions + handlers run inside the merged ``dsagt-server`` (see :mod:`dsagt.mcp.server`); ``create_knowledge_server`` is retained only as a @@ -18,7 +23,7 @@ import os -# Prevent fatal OpenMP crash when multiple libraries (FAISS, PyTorch/ +# Prevent fatal OpenMP crash when multiple libraries (PyTorch / # sentence-transformers) each bundle their own libomp. Must precede the # ``dsagt.knowledge`` import below. os.environ.setdefault("KMP_DUPLICATE_LIB_OK", "TRUE") @@ -33,12 +38,7 @@ import mcp.types as types # noqa: E402 -from dsagt.knowledge import ( # noqa: E402 - EMBEDDER_REGISTRY, - VECTORINDEX_REGISTRY, - CollectionRoute, - KnowledgeBase, -) +from dsagt.knowledge import KnowledgeBase # noqa: E402 from dsagt.mcp.server import build_dispatch_server # noqa: E402 from dsagt.session import _collection_exists # noqa: E402 from dsagt.session import setup_runtime_kb # noqa: E402, F401 (re-exported for tests) @@ -46,55 +46,6 @@ logger = logging.getLogger(__name__) -def _register_external_collection( - kb: KnowledgeBase, - collection_name: str, - vector_db: str, - connection_params: dict, - embedding_model: str, - description: str, -) -> None: - """Wire an already-built external vector store into the routing registry.""" - coll_dir = kb.index_dir / collection_name - coll_dir.mkdir(exist_ok=True) - - if description: - (coll_dir / "DESCRIPTION.md").write_text(description) - - if vector_db == "chroma": - index_kwargs = { - "collection_name": connection_params.get("collection", collection_name), - "persist_dir": None, - "host": connection_params.get("host", "localhost"), - "port": connection_params.get("port", 8000), - } - elif vector_db == "lancedb": - index_kwargs = { - "uri": connection_params["uri"], - "table": connection_params.get("table", collection_name), - } - elif vector_db == "qdrant": - index_kwargs = { - "url": connection_params["url"], - "collection": connection_params.get("collection", collection_name), - "api_key": connection_params.get("api_key"), - } - else: - raise ValueError( - f"Unsupported vector DB '{vector_db}'. " - f"Choose from: chroma, lancedb, qdrant" - ) - - route = CollectionRoute( - embedding_backend="api", - vector_db=vector_db, - embedder_kwargs={"model": embedding_model}, - index_kwargs=index_kwargs, - description=description, - ) - kb.register_route(collection_name, route) - - # --------------------------------------------------------------------------- # Background job tracker # --------------------------------------------------------------------------- @@ -189,36 +140,23 @@ async def _handle_kb_search( if len(where) > 1: where = {"$and": [{k: v} for k, v in where.items()]} - target_collections = collections_arg or [collection_arg] - all_results = [] - search_errors = [] - - for coll_name in target_collections: - try: - search_kwargs = dict( - query=query, collection=coll_name, top_k=top_k, rerank=rerank - ) - if where: - search_kwargs["where"] = where - coll_results = await asyncio.to_thread(kb.search, **search_kwargs) - all_results.extend(coll_results) - except ValueError as e: - logger.warning("Search failed for '%s': %s", coll_name, e) - search_errors.append(str(e)) - - if search_errors and not all_results: - if len(target_collections) == 1: - return {"status": "error", "error": search_errors[0]} - return { - "status": "error", - "error": f"All collections failed: {'; '.join(search_errors)}", - } - - score_key = "rerank_score" if rerank else "score" - all_results.sort(key=lambda r: r.get(score_key, r["score"]), reverse=True) - all_results = all_results[:top_k] + # Fan-out + rank-fusion across collections lives in KnowledgeBase.search; + # the tool just names collection(s). A single internal collection routes + # straight to its store; multiple/external targets federate by RRF. + try: + all_results = await asyncio.to_thread( + kb.search, + query=query, + collection=collection_arg, + collections=collections_arg, + top_k=top_k, + rerank=rerank, + where=where or None, + ) + except ValueError as e: + return {"status": "error", "error": str(e)} - result = { + return { "status": "ok", "query": query, "collection": collection_arg or ",".join(collections_arg), @@ -240,9 +178,6 @@ async def _handle_kb_search( for r in all_results ], } - if search_errors: - result["warnings"] = search_errors - return result async def _handle_kb_ingest( @@ -254,9 +189,6 @@ async def _handle_kb_ingest( folder_path = Path(arguments["folder_path"]) collection_name = arguments.get("collection_name") file_types = arguments.get("file_types") - embedding_backend = arguments.get("embedding_backend") - embedding_model = arguments.get("embedding_model") - vector_db = arguments.get("vector_db") if not folder_path.exists(): return {"status": "error", "error": f"Folder not found: {folder_path}"} @@ -298,21 +230,9 @@ async def _handle_kb_ingest( f"different folder; using '{target_name}'." ) - route = None - if embedding_backend or embedding_model or vector_db: - default = kb._default_route - inherited_model = embedding_model or default.embedder_kwargs.get("model") - route = CollectionRoute( - embedding_backend=embedding_backend or default.embedding_backend, - vector_db=vector_db or default.vector_db, - embedder_kwargs={"model": inherited_model} if inherited_model else {}, - ) - ingest_kwargs: dict = {"collection_name": target_name} if file_types: ingest_kwargs["file_types"] = file_types - if route is not None: - ingest_kwargs["route"] = route async def _ingest_with_logging(): import traceback as _tb @@ -379,43 +299,6 @@ async def _handle_kb_append( } -async def _handle_kb_add_vector_db(arguments: dict, *, kb: KnowledgeBase) -> dict: - collection_name = arguments["collection_name"] - vector_db = arguments["vector_db"] - connection_params = arguments["connection_params"] - embedding_model = arguments["embedding_model"] - description = arguments.get("description", "") - - if (kb.index_dir / collection_name).exists(): - return { - "status": "error", - "error": ( - f"Collection '{collection_name}' already exists. " - "Choose a different name or delete the existing collection." - ), - } - - await asyncio.to_thread( - _register_external_collection, - kb, - collection_name, - vector_db, - connection_params, - embedding_model, - description, - ) - return { - "status": "ok", - "collection": collection_name, - "vector_db": vector_db, - "embedding_model": embedding_model, - "message": ( - f"External collection '{collection_name}' registered. " - "Use search to query it." - ), - } - - async def _handle_kb_job_status(arguments: dict, *, job_tracker: _JobTracker) -> dict: job_id = arguments["job_id"] if job_id not in job_tracker.jobs: @@ -462,7 +345,6 @@ def _knowledge_tools_and_handlers(kb: KnowledgeBase): "kb_search": partial(_handle_kb_search, kb=kb), "kb_ingest": partial(_handle_kb_ingest, kb=kb, job_tracker=job_tracker), "kb_append": partial(_handle_kb_append, kb=kb, job_tracker=job_tracker), - "kb_add_vector_db": partial(_handle_kb_add_vector_db, kb=kb), "kb_job_status": partial(_handle_kb_job_status, job_tracker=job_tracker), } @@ -558,20 +440,6 @@ def _knowledge_tools_and_handlers(kb: KnowledgeBase): "items": {"type": "string"}, "description": "File extensions to include, e.g. ['pdf', 'md', 'py']. Defaults to common types.", }, - "embedding_backend": { - "type": "string", - "enum": list(EMBEDDER_REGISTRY.keys()), - "description": "Embedding backend override for this collection.", - }, - "embedding_model": { - "type": "string", - "description": "Embedding model override for this collection.", - }, - "vector_db": { - "type": "string", - "enum": list(VECTORINDEX_REGISTRY.keys()), - "description": "Vector database override for this collection.", - }, }, "required": ["folder_path"], }, @@ -604,45 +472,6 @@ def _knowledge_tools_and_handlers(kb: KnowledgeBase): "required": ["collection", "paths"], }, ), - types.Tool( - name="kb_add_vector_db", - description=( - "Register an already-built external vector store as a collection. " - "Queries will be embedded via the API using the specified model." - ), - inputSchema={ - "type": "object", - "properties": { - "collection_name": { - "type": "string", - "description": "Unique name for this collection", - }, - "vector_db": { - "type": "string", - "enum": ["chroma", "lancedb", "qdrant"], - "description": "Vector store backend type", - }, - "connection_params": { - "type": "object", - "description": "Backend-specific connection parameters.", - }, - "embedding_model": { - "type": "string", - "description": "The API model used to build this index", - }, - "description": { - "type": "string", - "description": "Human-readable description for agent discovery", - }, - }, - "required": [ - "collection_name", - "vector_db", - "connection_params", - "embedding_model", - ], - }, - ), types.Tool( name="kb_job_status", description="Check the status of a background ingest or append job.", diff --git a/src/dsagt/mcp/memory_tools.py b/src/dsagt/mcp/memory_tools.py index e0a2c86..c35be5f 100644 --- a/src/dsagt/mcp/memory_tools.py +++ b/src/dsagt/mcp/memory_tools.py @@ -53,18 +53,28 @@ async def _handle_kb_remember( "error": store_result.get("error", "Failed to store memory"), } - await asyncio.to_thread( - kb.add_entries, - texts=[text], - collection="session_memory", - metadatas=[ - { - "source_type": "explicit_memory", - "category": category, - "session_id": session_id, - } - ], - ) + # Mirror into the VectorStore for semantic recall — optional infra. + # The durable YAML write above already succeeded, so a mirror failure + # degrades to pure-YAML explicit memory rather than failing the tool. + try: + await asyncio.to_thread( + kb.add_entries, + texts=[text], + collection="session_memory", + metadatas=[ + { + "source_type": "explicit_memory", + "category": category, + "session_id": session_id, + } + ], + ) + except Exception as e: + logger.warning( + "kb_remember: stored YAML memory %s but vector mirror failed: %s", + store_result["entry_id"], + e, + ) if promoted_from: suggestions.dismiss(promoted_from) diff --git a/src/dsagt/mcp/registry_tools.py b/src/dsagt/mcp/registry_tools.py index d77fc7e..a1723ba 100644 --- a/src/dsagt/mcp/registry_tools.py +++ b/src/dsagt/mcp/registry_tools.py @@ -37,7 +37,7 @@ registry_reconstruct_pipeline_span, registry_save_tool_span, ) -from dsagt.provenance import reconstruct_pipeline +from dsagt.provenance import ToolUseIndexer, reconstruct_pipeline from dsagt.registry import TOOLS_COLLECTION, ToolRegistry logger = logging.getLogger(__name__) @@ -238,9 +238,19 @@ async def _handle_reconstruct_pipeline( arguments: dict, *, runtime_dir: Path, + kb: KnowledgeBase | None = None, ) -> str: fmt = arguments.get("format", "bash") trace_dir = runtime_dir / "trace_archive" + # Index the session's tool-use first: reconstruct is the moment the pipeline + # is "done enough" to review, so make the just-run executions searchable now + # rather than waiting on the heartbeat. Idempotent + file-locked, so this + # is safe to fire alongside the heartbeat's own ToolUseIndexer. + if kb is not None: + try: + ToolUseIndexer(kb, runtime_dir).tick() + except Exception as e: # noqa: BLE001 — indexing is best-effort here + logger.warning("tool_use indexing before reconstruct failed: %s", e) with registry_reconstruct_pipeline_span(fmt): try: script = reconstruct_pipeline(trace_dir, fmt=fmt) @@ -314,7 +324,7 @@ def _registry_tools_and_handlers( "get_registry": partial(_handle_get_registry, registry=registry), "search_registry": partial(_handle_search_registry, registry=registry, kb=kb), "reconstruct_pipeline": partial( - _handle_reconstruct_pipeline, runtime_dir=runtime_dir + _handle_reconstruct_pipeline, runtime_dir=runtime_dir, kb=kb ), "install_dependencies": partial( _handle_install_dependencies, registry=registry diff --git a/src/dsagt/mcp/server.py b/src/dsagt/mcp/server.py index df46cee..f291f70 100644 --- a/src/dsagt/mcp/server.py +++ b/src/dsagt/mcp/server.py @@ -30,7 +30,7 @@ import os -# Set before any import that may pull in FAISS / PyTorch / sentence-transformers +# Set before any import that may pull in PyTorch / sentence-transformers # (e.g. ``dsagt.knowledge`` below): prevents a fatal OpenMP crash when multiple # libraries each bundle their own libomp. os.environ["PYTHONUNBUFFERED"] = "1" @@ -39,6 +39,7 @@ import asyncio # noqa: E402 import json # noqa: E402 import logging # noqa: E402 +import threading # noqa: E402 from pathlib import Path # noqa: E402 import yaml # noqa: E402 @@ -96,20 +97,115 @@ async def call_tool(tool_name: str, arguments: dict) -> list[types.TextContent]: return server -async def _run_stdio(server: Server, name: str) -> None: +HEARTBEAT_INTERVAL_S = 45.0 + + +async def _heartbeat(collector, tool_indexer, interval: float) -> None: + """Periodically run the trace collector + tool-use indexer on wall-clock time. + + Runs regardless of tool traffic, so a quiet session (the agent thinking, + editing with its own tools, plain chat) is still captured. Both block on + disk (+ MLflow / embedding), so they run in a worker thread to keep handlers + responsive; a failure is logged, never fatal. + """ + while True: + await asyncio.sleep(interval) + if collector is not None: + try: + n = await asyncio.to_thread(collector.collect) + if n: + logger.info("Trace heartbeat: logged %d trace(s)", n) + except Exception as e: # noqa: BLE001 + logger.warning("Trace heartbeat collect failed: %s", e) + if tool_indexer is not None: + try: + n = await asyncio.to_thread(tool_indexer.tick) + if n: + logger.info("Tool-use heartbeat: indexed %d record(s)", n) + except Exception as e: # noqa: BLE001 + logger.warning("Tool-use heartbeat tick failed: %s", e) + + +def _episodic_consumers(config, kb, project_dir, session_id): + """Build the episodic-memory consumer list from config (empty when off). + + Episodic memory is a compute/storage opt-in (``episodic.enabled``); the + Tier-1 ``Judge`` is attached only when a backend is configured, otherwise + the consumer runs Tier-0 (mechanical, no LLM). Best-effort: a build + failure leaves the collector with just the MLflow logger. + """ + epi = config.get("episodic", {}) or {} + if not epi.get("enabled"): + return [] + try: + from dsagt.memory import MemoryExtractor + + judge = None + jcfg = epi.get("judge", {}) or {} + if jcfg.get("backend"): + from dsagt.judge import Judge + + judge = Judge.create(jcfg["backend"], model=jcfg.get("model") or None) + return [ + MemoryExtractor( + kb, + runtime_dir=str(project_dir), + session_id=session_id or "", + tags=epi.get("domain_tags") or None, + judge=judge, + outlier_sensitivity=float(epi.get("outlier_sensitivity", 0.0) or 0.0), + ) + ] + except Exception as e: # noqa: BLE001 — memory is best-effort, never fatal + logger.warning("Could not build episodic-memory consumer: %s", e) + return [] + + +async def _run_stdio( + server: Server, name: str, collector=None, tool_indexer=None +) -> None: async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - InitializationOptions( - server_name=name, - server_version="0.1.0", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), - ), + hb = ( + asyncio.create_task( + _heartbeat(collector, tool_indexer, HEARTBEAT_INTERVAL_S) + ) + if (collector is not None or tool_indexer is not None) + else None ) + try: + await server.run( + read_stream, + write_stream, + InitializationOptions( + server_name=name, + server_version="0.1.0", + capabilities=server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ), + ) + finally: + if hb is not None: + hb.cancel() + try: + await hb + except asyncio.CancelledError: + pass + # Best-effort end-of-session flush of the deferred final turn + + # any unindexed tool-use. Non-load-bearing: if killed, the next + # session's startup catch-up re-reads the tail (both ack-sets + # make it idempotent). + if collector is not None: + try: + await asyncio.to_thread(collector.collect, include_last=True) + except Exception as e: # noqa: BLE001 + logger.warning("Trace heartbeat final flush failed: %s", e) + if tool_indexer is not None: + try: + await asyncio.to_thread(tool_indexer.tick) + except Exception as e: # noqa: BLE001 + logger.warning("Tool-use final flush failed: %s", e) # --------------------------------------------------------------------------- @@ -165,8 +261,9 @@ def _build_kb_from_config(config: dict, project_dir: Path) -> KnowledgeBase: """ from dsagt.session import REGISTRY_DIR, setup_runtime_kb - kb_config = config["knowledge"] - emb_config = config["embedding"] + # embedding is a backfilled code default (not a written config choice); + # chunk_size / rerank default in KnowledgeBase itself. + emb_config = config.get("embedding", {}) backend = (emb_config.get("backend") or "local").lower() if backend not in ("local", "api"): @@ -178,46 +275,50 @@ def _build_kb_from_config(config: dict, project_dir: Path) -> KnowledgeBase: # OpenAI-style aliases ("text-embedding-3-small") share the same # EMBEDDING_MODEL env var in most setups. When backend=local but the # resolved model is an OpenAI-style alias (no slash), drop the override so - # we fall back to the LocalEmbeddingClient default rather than 404 from HF. + # we fall back to the LocalEmbedder default rather than 404 from HF. raw_model = (emb_config.get("model") or "").strip() - embedder_kwargs: dict = {} + model: str | None = None + base_url: str | None = None + api_key: str | None = None if raw_model and not raw_model.startswith("${"): looks_hf = "/" in raw_model if backend == "local" and not looks_hf: logger.warning( "Ignoring embedding.model=%r for backend=local (does not look " "like a HuggingFace identifier). Falling back to the " - "LocalEmbeddingClient default.", + "LocalEmbedder default.", raw_model, ) else: - embedder_kwargs["model"] = raw_model + model = raw_model if backend == "api": base_url = emb_config.get("base_url") or "" - api_key = emb_config.get("api_key") or "" + # Credentials are never on disk: the api key comes from the shell env + # (EMBEDDING_API_KEY), threaded into MCP children via the env block. + api_key = os.environ.get("EMBEDDING_API_KEY") or emb_config.get("api_key") or "" if not base_url: raise ValueError( "embedding.backend='api' requires embedding.base_url in " - "dsagt_config.yaml. Either set it to your OpenAI-compatible " + ".dsagt/config.yaml. Either set it to your OpenAI-compatible " "endpoint, or change backend to 'local'." ) if not api_key or api_key.startswith("${"): raise ValueError( - "embedding.backend='api' requires embedding.api_key in " - "dsagt_config.yaml. Either fill it in (or export the " - "${EMBEDDING_API_KEY} env var), or change backend to 'local'." + "embedding.backend='api' requires the EMBEDDING_API_KEY env " + "var (export it in your shell), or change backend to 'local'." ) - embedder_kwargs.update({"base_url": base_url, "api_key": api_key}) + + from dsagt.session import _recency_half_life runtime_kb_dir = setup_runtime_kb(REGISTRY_DIR / "kb_index", project_dir) logger.info("Knowledge backend: %s", backend) kb = KnowledgeBase( index_dir=runtime_kb_dir, - chunk_size=kb_config["chunk_size"], - default_rerank=kb_config["rerank"], default_embedder=backend, - default_index=kb_config["vector_db"], - embedder_kwargs=embedder_kwargs, + model=model, + base_url=base_url, + api_key=api_key, + recency_half_life_days=_recency_half_life(config), ) # Background-load the embedder so the model is ready when the agent's first # search / kb call lands (otherwise the first call pays the ~5-10s @@ -226,28 +327,58 @@ def _build_kb_from_config(config: dict, project_dir: Path) -> KnowledgeBase: return kb +def _spawn_catch_up(project_dir: Path, config: dict) -> None: + """Run :func:`dsagt.session.catch_up_extraction` in a daemon thread. + + Best-effort background catch-up of the previous session's post-session + work (tool-use indexing now; episodic stub later). Daemon so it never + holds the server open; exceptions are logged, never propagated. + """ + + def _run() -> None: + try: + from dsagt.session import catch_up_extraction + + result = catch_up_extraction(project_dir, config) + logger.info("Background catch-up complete: %s", result) + except Exception as e: # noqa: BLE001 + logger.warning("Background catch-up failed: %s", e) + + threading.Thread(target=_run, name="dsagt-catch-up", daemon=True).start() + + def main(): """Entry point for ``dsagt-server``. All configuration comes from the project directory: - - ``./dsagt_config.yaml`` → project path + non-secret settings + - ``./.dsagt/config.yaml`` → project path + non-secret settings - ``EMBEDDING_*`` env vars → embedding credentials No CLI arguments. By contract the agent's launch one-liner is ``cd && ``, so cwd is project_dir for the MCP children it spawns. + + The server owns the session lifecycle: it appends a new entry to + ``.dsagt/state.yaml`` (minting the session id) and spawns a background + thread that catches up post-session extraction for the *previous* + session — no reliable session-end trigger needed. """ from dsagt.observability import ( - configure_litellm_retries, find_project_config, init_tracing, ) - from dsagt.session import resolve_env_vars + from dsagt.session import ( + DEFAULTS, + _deep_merge, + append_session, + resolve_env_vars, + session_tag, + ) project_dir, _cfg = find_project_config() if project_dir is None: raise RuntimeError( - "dsagt-server: no dsagt_config.yaml in cwd " + "dsagt-server: no .dsagt/config.yaml in cwd " f"({Path.cwd()}). Launch the agent from the project " "directory (`cd && `)." ) @@ -269,16 +400,34 @@ def main(): ) logger.info("Server starting — project_dir: %s, log: %s", project_dir, log_file) - config_path = project_dir / "dsagt_config.yaml" - config = resolve_env_vars(yaml.safe_load(config_path.read_text())) + cfg_file = project_dir / ".dsagt" / "config.yaml" + # Backfill code defaults (embedding, etc.) the same way ``load_config`` + # does — the written config carries only the user's init choices. + config = resolve_env_vars( + _deep_merge(DEFAULTS, yaml.safe_load(cfg_file.read_text()) or {}) + ) + + # Own the session lifecycle: mint this session's id into state.yaml and + # tag traces with it (replaces the DSAGT_SESSION_ID env minted by the old + # ``dsagt start``). Best-effort — never block startup on state I/O. + session_id = None + try: + entry = append_session(project_dir) + session_id = session_tag(config.get("project", ""), entry["id"]) + except Exception as e: # noqa: BLE001 + logger.warning("Could not mint session into state.yaml: %s", e) + + init_tracing("dsagt-server", session_id=session_id) - init_tracing("dsagt-server") # session_id picked up from DSAGT_SESSION_ID env - configure_litellm_retries() + # Catch up post-session extraction for the previous session in the + # background (tool-use indexing now; episodic stub later). Daemon thread: + # best-effort, never blocks or fails server startup. + _spawn_catch_up(project_dir, config) kb = _build_kb_from_config(config, project_dir) # Bundled tools are pre-embedded in the shared ~/dsagt-projects/kb_index/ - # by ``dsagt setup-kb`` (or the auto-bootstrap in ``dsagt start``) and + # by ``dsagt init`` (shared cache, one-time per machine) and # copied into the project's kb_index by ``setup_runtime_kb`` above. No # bundled embedding work happens here; save_tool_spec incurs a single # embed at save time. @@ -294,8 +443,43 @@ def main(): ) server = create_dsagt_server(registry, kb, skill_reg, runtime_dir=str(project_dir)) + + # The in-session trace heartbeat: read the live transcript → MLflow. The + # loop is agent-agnostic; ``make_trace_collector`` returns a collector for any + # agent with a registered (reader, translator) pair and ``None`` otherwise (so + # agents whose readers haven't landed yet simply run without it). + # Best-effort — a collector that can't be built never blocks the server. + collector = None + try: + from dsagt.observability import resolve_tracking_uri + from dsagt.traces import make_trace_collector + + resolve_cfg = dict(config) + resolve_cfg["project_dir"] = str(project_dir) + collector = make_trace_collector( + config.get("agent"), + project_dir, + config.get("project", ""), + session_id or "", + resolve_tracking_uri(resolve_cfg), + extra_consumers=_episodic_consumers(config, kb, project_dir, session_id), + ) + except Exception as e: # noqa: BLE001 + logger.warning("Could not start trace heartbeat: %s", e) + + # Tool-use indexer: incremental, idempotent embedding of dsagt-run records + # into the ``tool_use`` collection on the same heartbeat (no collector + # dependency — it reads trace_archive/, not the transcript). + tool_indexer = None + try: + from dsagt.provenance import ToolUseIndexer + + tool_indexer = ToolUseIndexer(kb, project_dir) + except Exception as e: # noqa: BLE001 + logger.warning("Could not start tool-use indexer: %s", e) + try: - asyncio.run(_run_stdio(server, "dsagt")) + asyncio.run(_run_stdio(server, "dsagt", collector, tool_indexer)) finally: kb.close() diff --git a/src/dsagt/mcp/skill_tools.py b/src/dsagt/mcp/skill_tools.py index 91bc140..2bc195c 100644 --- a/src/dsagt/mcp/skill_tools.py +++ b/src/dsagt/mcp/skill_tools.py @@ -198,7 +198,7 @@ async def _handle_list_skill_sources(arguments: dict, *, kb: KnowledgeBase) -> d "then search_skills to browse. search_skills only sees synced sources." if any_synced else "No catalog synced yet — add_skill_source " - "(e.g. 'scientific') to enable one, then search_skills to browse." + "(e.g. 'k-dense-ai') to enable one, then search_skills to browse." ), } @@ -309,7 +309,7 @@ def _skill_tools_and_handlers( name="add_skill_source", description=( "Enable an external agent-skill source (a known name like " - "'scientific'/'anthropic'/'antigravity'/'composio', or a GitHub URL). " + "'k-dense-ai'/'anthropic'/'antigravity'/'composio', or a GitHub URL). " "Clones it and indexes its skills into the searchable catalog " "(search_skills). Does NOT load them into context." ), diff --git a/src/dsagt/memory.py b/src/dsagt/memory.py index 982cb07..d1dabe1 100644 --- a/src/dsagt/memory.py +++ b/src/dsagt/memory.py @@ -10,32 +10,24 @@ demand via the ``kb_get_memories`` / ``kb_search`` MCP tools. Supports remember, supersede, remove, and retrieval. -**Episodic memory** (extract_session and friends): - End-of-session LLM extraction of facts, summaries, and insights. - Conversation history comes from MLflow traces with - ``service.name = "dsagt-proxy"`` — i.e., LLM calls forwarded - through ``dsagt-proxy`` and autologged via - ``mlflow.litellm.autolog()`` into a uniform shape - (``mlflow.spanInputs`` = request kwargs with ``messages``, - ``mlflow.spanOutputs`` = provider-specific response). - - Native agent OTel emission (Claude Code, Goose) does NOT feed - extraction, even though those traces are visible in the MLflow UI. - The reason is shape divergence: Claude Code puts conversation in - span events (``api_response_body``), Goose puts tool calls in - ``dispatch_tool_call`` spans with a domain-specific schema, and - LiteLLM autolog uses ``mlflow.spanInputs`` / ``mlflow.spanOutputs``. - Parsing all three would mean three parallel parsers in this module - and per-agent maintenance forever. Instead, extraction reads one - canonical shape (the one ``dsagt-proxy`` emits via autolog) and - users who want extraction run ``dsagt start --enable-proxy``. - - ``drain_session_traces`` queries proxy-shape traces in the session, - skips ones already tagged with ``dsagt.memory.extracted = "true"``, - formats each into the exchange shape the prompt expects, and tags - consumed traces so re-runs are idempotent. Stored in the - ``episodic_memory`` ChromaDB collection. Includes outlier - detection via per-category embedding centroids. +**Episodic memory** (MemoryExtractor — Phase 3): + A trace-pipeline *consumer* (``MemoryExtractor``) that consumes the + in-process ``Trace`` the heartbeat produces and writes tagged + facts into the ``session_memory`` collection. Two tiers: + + * **Tier-0 (mechanical, always):** chunk + mechanical-tag + embed + each turn — no LLM, the universal fallback for every agent. + * **Tier-1 (distilled, opt-in):** a small local LLM (``judge.Judge`` + — ``LocalJudge`` by default) tags + condenses each turn into + ≤1-sentence facts. On judge failure it degrades to Tier-0 — never + lose data, never block. + + The per-category-centroid outlier detection (``CategoryCentroids`` / + ``SuggestionQueue``) gives principled, user-confirmed novelty review on + top of the distilled facts. ``extract_session`` (the old end-of-session + entry point) remains a no-op stub: the heartbeat consumer is the live + path; ``extract_session`` is retained only for the deferred cross-session + N+1 catch-up call site. Files on disk (in project directory): explicit_memories.yaml — active user-confirmed facts @@ -49,14 +41,21 @@ import hashlib import json import logging +import re +import time from datetime import datetime, timezone from pathlib import Path +from typing import TYPE_CHECKING import numpy as np import yaml -from dsagt.knowledge import CollectionRoute, KnowledgeBase +from dsagt.knowledge import KnowledgeBase + +if TYPE_CHECKING: + from dsagt.judge import Judge + from dsagt.traces import Trace logger = logging.getLogger(__name__) @@ -65,6 +64,7 @@ # Explicit memory (YAML store) # --------------------------------------------------------------------------- + def _now_iso() -> str: return datetime.now(timezone.utc).isoformat() @@ -97,7 +97,8 @@ def _save(self, entries: list[dict]) -> None: self._dir.mkdir(parents=True, exist_ok=True) self._path.write_text( yaml.dump(entries, default_flow_style=False, sort_keys=False) - if entries else "" + if entries + else "" ) def _append_history(self, entry: dict) -> None: @@ -214,11 +215,15 @@ def render_context(self) -> str: # was otherwise configured for local embeddings, hanging the agent # for ~60s per call on the retry backoff. +# The stock "AI-data-ready" taxonomy: a small, closed, domain-neutral label set +# — the *closedness* is what makes the Tier-1 LocalJudge viable (small models +# classify into a fixed set reliably; "invent a category" is what they fail at). +# Init solicits per-project domain tags that merge on top (the genomics-specific +# ``assembly`` that used to live here is now exactly such a user tag, not stock). STOCK_CATEGORIES = { "quality_control": "Assessment or filtering of data quality, QC metrics, thresholds, pass/fail rates", "data_management": "File organization, data movement, format conversion, naming conventions", "transformation": "Data processing steps, parameter choices, pipeline stage configuration", - "assembly": "Genome assembly, contig generation, scaffolding, assembly QC metrics", "configuration": "Tool settings, environment setup, resource allocation decisions", "performance": "Runtime, memory usage, throughput, resource consumption observations", "tool_usage": "Tool selection rationale, parameter tuning, tool-specific behaviors or quirks", @@ -228,192 +233,14 @@ def render_context(self) -> str: DEFAULT_SENSITIVITY = 0.35 -# --------------------------------------------------------------------------- -# Session-trace reading (MLflow) -# --------------------------------------------------------------------------- - -#: Trace tag we set after consuming a trace into an extraction run, so -#: re-runs of ``extract_session`` for the same session don't double-feed -#: the same exchange into the LLM. ``MlflowClient.set_trace_tag`` is the -#: idempotent equivalent of the old ``drain → unlink`` pattern. -DSAGT_MEMORY_PROCESSED_TAG = "dsagt.memory.extracted" - - -def _safe_parse_json(value): - """Parse value as JSON; return as-is if already a dict/list.""" - if isinstance(value, (dict, list)): - return value - if not isinstance(value, str) or not value: - return None - try: - return json.loads(value) - except (TypeError, ValueError): - return None - - -def _trace_to_exchange(row) -> dict | None: - """Format one MLflow ``search_traces`` row as an extraction-prompt exchange. - - The exchange shape mirrors what the proxy used to write to - ``session_log.jsonl`` so ``_render_conversation`` doesn't need to - change: - - {"timestamp": ..., "trace_id": ..., "model": ..., - "new_messages": [...], "response": [...content blocks...]} - - Returns None when the trace doesn't carry recognisable LLM-call - request/response shape (e.g. tool-execute spans, kb.* spans) so - callers can skip silently. - """ - request = _safe_parse_json(row.get("request")) - response = _safe_parse_json(row.get("response")) - - messages = request.get("messages") if isinstance(request, dict) else None - if not messages: - return None - - response_blocks: list[dict] = [] - if isinstance(response, dict): - # Anthropic shape: top-level ``content`` already a list of blocks. - if isinstance(response.get("content"), list): - response_blocks.extend(response["content"]) - # OpenAI shape: choices[].message.content (str or block list) plus - # tool_calls (translated to tool_use blocks for prompt consistency). - for choice in response.get("choices") or []: - msg = choice.get("message") or {} - content = msg.get("content") - if isinstance(content, str) and content: - response_blocks.append({"type": "text", "text": content}) - elif isinstance(content, list): - response_blocks.extend(content) - for tc in msg.get("tool_calls") or []: - func = tc.get("function") or {} - response_blocks.append({ - "type": "tool_use", - "id": tc.get("id"), - "name": func.get("name"), - "input": _safe_parse_json(func.get("arguments")) or {}, - }) - - return { - "timestamp": str(row.get("request_time") or ""), - "trace_id": row.get("trace_id") or "", - "model": (request.get("model") if isinstance(request, dict) else "") or "", - "new_messages": messages, - "response": response_blocks, - } - - -#: Service name extraction reads from. Restricting to ``dsagt-proxy`` -#: keeps the parser simple — every trace emitted by the proxy is -#: guaranteed LiteLLM-autolog shape (``mlflow.spanInputs`` / -#: ``mlflow.spanOutputs``), the same shape ``_trace_to_exchange`` parses. -#: Native agent traces (claude-code, goose) skip extraction by design; -#: see module docstring for why. -DSAGT_EXTRACTION_SOURCE_SERVICE_NAME = "dsagt-proxy" - - -def drain_session_traces( - project_name: str, session_id: str, mlflow_uri: str | None = None, -) -> list[dict]: - """Pull untagged proxy-shape session traces, format as exchanges, tag. - - Uses ``mlflow.search_traces`` to find traces in the project's - experiment whose ``mlflow.trace.session`` metadata matches - *session_id*, then filters to those emitted by ``dsagt-proxy`` - (the only canonical-shape source — see module docstring). Skips - ones already tagged with ``DSAGT_MEMORY_PROCESSED_TAG``; tags each - consumed trace so a subsequent extraction run on the same session - is a no-op. - - Returns a list of exchange dicts in chronological order. Empty - list when no experiment, no proxy-shape traces, or every trace was - already processed. Importantly, an empty result when the user ran - without ``--enable-proxy`` is *expected*: native-OTel agents - (Claude Code, Goose) emit traces in shapes this parser doesn't - handle, and the design choice is to require the proxy for - extraction rather than maintain N per-agent parsers. - """ - import os - import mlflow - from mlflow.tracking import MlflowClient - - uri = mlflow_uri or os.environ.get("MLFLOW_TRACKING_URI") - if not uri: - logger.warning("MLFLOW_TRACKING_URI not set; cannot drain session traces") - return [] - - mlflow.set_tracking_uri(uri) - client = MlflowClient(uri) - exp = client.get_experiment_by_name(project_name) - if exp is None: - return [] - - df = mlflow.search_traces( - locations=[exp.experiment_id], - filter_string=f"metadata.`mlflow.trace.session` = '{session_id}'", - max_results=10000, - order_by=["timestamp_ms ASC"], - ) - if df is None or df.empty: - return [] - - exchanges: list[dict] = [] - consumed_ids: list[str] = [] - for _, row in df.iterrows(): - tags = row.get("tags") or {} - if isinstance(tags, dict) and tags.get(DSAGT_MEMORY_PROCESSED_TAG) == "true": - continue - if not _trace_emitted_by(row, DSAGT_EXTRACTION_SOURCE_SERVICE_NAME): - continue - ex = _trace_to_exchange(row) - if ex: - exchanges.append(ex) - # Tag every proxy-shape trace we considered (parsed or not), so - # the next run doesn't re-inspect them. Non-proxy traces are - # left untagged on purpose — a future extraction run that - # broadens the source filter will pick them up. - trace_id = row.get("trace_id") - if trace_id: - consumed_ids.append(trace_id) - - for trace_id in consumed_ids: - try: - client.set_trace_tag(trace_id, DSAGT_MEMORY_PROCESSED_TAG, "true") - except Exception as e: - # Tag failure is non-fatal — worst case we re-extract that trace - # next session, which is annoying but not data-destroying. - logger.debug("set_trace_tag(%s) failed: %s", trace_id, e) - - return exchanges - - -def _trace_emitted_by(row, service_name: str) -> bool: - """Return True when *row*'s root span carries ``service.name == name``. - - MLflow's OTLP receiver flows the OTel resource attribute through to - each span's attributes, so every span in a trace from a given - process carries the same ``service.name``. We check the first span - we find — the shape varies (Span object / dict / pandas Series) - across MLflow versions, mirroring info.py:_source_from_spans. - """ - spans = row.get("spans") or [] - try: - for span in spans: - attrs = getattr(span, "attributes", None) - if attrs is None and isinstance(span, dict): - attrs = span.get("attributes") - if attrs and attrs.get("service.name") == service_name: - return True - except (TypeError, AttributeError): - pass - return False - - # --------------------------------------------------------------------------- # Extraction prompt construction +# +# Prompt/parse building blocks for the Phase-3 Trace-based +# extractor (see ``extract_session``). # --------------------------------------------------------------------------- + def _render_conversation(exchanges: list[dict]) -> str: lines = [] for i, ex in enumerate(exchanges, 1): @@ -450,7 +277,8 @@ def _extract_block_text(block: dict) -> str: return content if isinstance(content, list): return " ".join( - b.get("text", "") for b in content + b.get("text", "") + for b in content if isinstance(b, dict) and b.get("type") == "text" ) return str(content) @@ -514,6 +342,7 @@ def build_extraction_prompt( # Response parsing # --------------------------------------------------------------------------- + def parse_extraction_response(response_text: str) -> dict: """Parse the LLM's JSON response into facts, summary, and insights.""" text = response_text.strip() @@ -532,50 +361,11 @@ def parse_extraction_response(response_text: str) -> dict: } -# --------------------------------------------------------------------------- -# LLM call -# --------------------------------------------------------------------------- - -def call_extraction_llm( - prompt: str, - api_key: str, - model: str = "claude-sonnet-4-20250514", - base_url: str | None = None, - provider: str | None = None, -) -> str: - """Call the LLM with the extraction prompt. Returns raw response text. - - Uses LiteLLM so the call works against any OpenAI- or Anthropic-format - upstream the user's ``llm.base_url`` points at. Hand-rolling an - Anthropic-format request would 404 against an OpenAI-compatible gateway - (e.g. PNNL's ai-incubator-api), and vice versa. - """ - import litellm - - # Mirror what the proxy does (commands/proxy_server.py): prefix the model - # with the configured provider so LiteLLM picks the right request format. - # Falls back to ``openai`` when provider is unset, preserving the historic - # behavior for callers that don't yet thread ``llm.provider`` through. - if base_url: - completion_model = f"{provider or 'openai'}/{model}" - else: - completion_model = model - - response = litellm.completion( - model=completion_model, - api_base=base_url, - api_key=api_key, - messages=[{"role": "user", "content": prompt}], - max_tokens=4096, - timeout=120.0, - ) - return response.choices[0].message.content or "" - - # --------------------------------------------------------------------------- # Outlier detection: category centroids # --------------------------------------------------------------------------- + class CategoryCentroids: """Maintains running centroids per category.""" @@ -635,6 +425,7 @@ def count(self, category: str) -> int: # Outlier detection: suggestion queue # --------------------------------------------------------------------------- + class SuggestionQueue: """Manages pending outlier suggestions on disk.""" @@ -644,19 +435,24 @@ def __init__(self, path: Path): if path.exists(): self._suggestions = json.loads(path.read_text()) - def add(self, text: str, category: str, distance: float, session_id: str = "") -> str: - suggestion_id = "sug_" + hashlib.sha256( - f"{text}:{category}:{session_id}".encode() - ).hexdigest()[:8] + def add( + self, text: str, category: str, distance: float, session_id: str = "" + ) -> str: + suggestion_id = ( + "sug_" + + hashlib.sha256(f"{text}:{category}:{session_id}".encode()).hexdigest()[:8] + ) - self._suggestions.append({ - "id": suggestion_id, - "text": text, - "category": category, - "distance": round(distance, 4), - "session_id": session_id, - "timestamp": datetime.now(timezone.utc).isoformat(), - }) + self._suggestions.append( + { + "id": suggestion_id, + "text": text, + "category": category, + "distance": round(distance, 4), + "session_id": session_id, + "timestamp": datetime.now(timezone.utc).isoformat(), + } + ) self._save() return suggestion_id @@ -690,6 +486,7 @@ def _save(self) -> None: # Outlier detection: check and queue # --------------------------------------------------------------------------- + def check_and_queue_outliers( texts: list[str], categories: list[str], @@ -712,18 +509,189 @@ def check_and_queue_outliers( continue if distance > threshold: - sid = queue.add(text=text, category=category, distance=distance, session_id=session_id) + sid = queue.add( + text=text, category=category, distance=distance, session_id=session_id + ) suggestion_ids.append(sid) - logger.info("Outlier flagged (distance=%.3f, threshold=%.3f): %s", distance, threshold, text[:80]) + logger.info( + "Outlier flagged (distance=%.3f, threshold=%.3f): %s", + distance, + threshold, + text[:80], + ) centroids.save() return suggestion_ids +# --------------------------------------------------------------------------- +# Mechanical (Tier-0) tagging +# --------------------------------------------------------------------------- + +_WORD_RE = re.compile(r"[a-z][a-z0-9_]{2,}") + + +def _mechanical_tag(text: str, tags: dict[str, str]) -> str: + """Pick the best-matching tag by keyword overlap, or ``""`` for none. + + Tier-0's no-LLM classifier: score each tag by how many of its description + words appear in the turn text, take the best non-zero. Coarse by design — + Tier-1's LocalJudge is the accurate path; this only has to be *useful* as + the universal fallback, and an empty tag (uncategorized) is an honest result + when nothing matches rather than a forced wrong label. + """ + words = set(_WORD_RE.findall(text.lower())) + if not words: + return "" + best_tag, best_score = "", 0 + for name, desc in tags.items(): + score = len(words & set(_WORD_RE.findall(desc.lower()))) + if score > best_score: + best_tag, best_score = name, score + return best_tag + + +# --------------------------------------------------------------------------- +# Episodic memory: the trace-pipeline consumer +# --------------------------------------------------------------------------- + + +def _epoch_or_now(ts: object) -> float: + """An exchange timestamp (epoch seconds) → float, else wall-clock now. + + ``Trace.to_exchanges`` carries the span ``start_time`` (epoch + seconds) when the transcript recorded it, ``None`` otherwise — recency + weighting needs a number, so fall back to now for unstamped turns. + """ + return float(ts) if isinstance(ts, (int, float)) else time.time() + + +def _batch_epoch(exchanges: list[dict]) -> float: + """The most-recent exchange timestamp in a turn batch (for distilled facts).""" + stamps = [ + e["timestamp"] + for e in exchanges + if isinstance(e.get("timestamp"), (int, float)) + ] + return max(stamps) if stamps else time.time() + + +class MemoryExtractor: + """Trace-pipeline consumer: ``Trace`` → tagged ``session_memory`` facts. + + Plugged into :class:`~dsagt.traces.TraceCollector` alongside the MLflow sink; + each ``write`` receives the (subset of) just-completed turns and indexes them. + Idempotency is the heartbeat's job — it only delivers turns this consumer + hasn't acked — so ``write`` just does the work. + + Tier selection: with a ``judge`` it runs Tier-1 (distilled facts) and falls + back to Tier-0 (mechanical chunk) if the judge raises; without one it runs + Tier-0 directly. Either way nothing is lost and the agent is never blocked. + """ + + #: Subscriber name → its own ack file (``.dsagt/trace_acks_memory.json``). + name = "memory" + + def __init__( + self, + kb: KnowledgeBase, + *, + runtime_dir: str | Path, + session_id: str = "", + tags: dict[str, str] | None = None, + judge: "Judge | None" = None, + outlier_sensitivity: float = DEFAULT_SENSITIVITY, + ): + self._kb = kb + self._runtime_dir = Path(runtime_dir) + self._session_id = session_id + self._tags = {**STOCK_CATEGORIES, **(tags or {})} + self._judge = judge + self._sensitivity = outlier_sensitivity + + def write(self, trace: "Trace") -> None: + exchanges = trace.to_exchanges() + if not exchanges: + return + if self._judge is None: + self._index_tier0(exchanges) + return + try: + facts = self._judge.distill(exchanges, self._tags) + except Exception as e: # designed degradation (plan §5), not a swallow + # *Judge* failure (not a store error) degrades to Tier-0 so the turn + # is never lost. A store-layer failure below is left to propagate — + # TraceCollector's per-consumer isolation stops it blocking the agent, + # and Tier-0 would hit the same KB anyway. + logger.warning("Tier-1 judge failed (%s); falling back to Tier-0", e) + self._index_tier0(exchanges) + return + # A judge that legitimately found nothing durable (most turns) stores + # nothing — that's the empty escape, not a failure, so no Tier-0 redo. + self._store_facts(facts, _batch_epoch(exchanges)) + + def _index_tier0(self, exchanges: list[dict]) -> None: + """Mechanical: render each turn, mechanically tag it, embed into session_memory.""" + texts, metas = [], [] + for ex in exchanges: + text = _render_conversation([ex]).strip() + if not text: + continue + texts.append(text) + metas.append( + { + "session_id": self._session_id, + "source_type": "turn", + "category": _mechanical_tag(text, self._tags), + "tier": "0", + "ts_epoch": _epoch_or_now(ex.get("timestamp")), + } + ) + if texts: + self._kb.add_entries( + texts=texts, collection=SESSION_MEMORY_COLLECTION, metadatas=metas + ) + + def _store_facts(self, facts: list[dict], ts_epoch: float) -> None: + """Store Tier-1 distilled ``[{text, tag}]`` facts + outlier-detect.""" + if not facts: + return + texts = [f["text"] for f in facts] + cats = [f.get("tag", "") for f in facts] + metas = [ + { + "session_id": self._session_id, + "source_type": "fact", + "category": c, + "tier": "1", + "ts_epoch": ts_epoch, + } + for c in cats + ] + need_embeddings = self._sensitivity > 0 + result = self._kb.add_entries( + texts=texts, + collection=SESSION_MEMORY_COLLECTION, + metadatas=metas, + return_embeddings=need_embeddings, + ) + if need_embeddings: + check_and_queue_outliers( + texts=texts, + categories=cats, + embeddings=result["embeddings"], + centroids=CategoryCentroids(self._runtime_dir / "centroids.json"), + queue=SuggestionQueue(self._runtime_dir / "suggestions.json"), + threshold=self._sensitivity, + session_id=self._session_id, + ) + + # --------------------------------------------------------------------------- # End-to-end extraction # --------------------------------------------------------------------------- + def extract_session( project_name: str, kb: KnowledgeBase, @@ -738,97 +706,36 @@ def extract_session( mlflow_uri: str | None = None, exchanges: list[dict] | None = None, ) -> dict: - """Extract memories from a session's MLflow traces; store in episodic_memory. + """Episodic-memory extraction — a no-op stub until Phase 3. - *project_name* is the MLflow experiment name (matches ``DSAGT_PROJECT``). - *session_id* selects which traces to consume; required when *exchanges* - is not supplied. Tests pass *exchanges* directly to bypass MLflow. - """ - if not session_id: - return {"status": "empty", "facts": 0, "insights": 0, - "reason": "no_session_id"} - - if exchanges is None: - exchanges = drain_session_traces(project_name, session_id, mlflow_uri) - if not exchanges: - return {"status": "empty", "facts": 0, "insights": 0} - - prompt = build_extraction_prompt(exchanges, categories) - response_text = call_extraction_llm(prompt, api_key, model, base_url, provider) - extracted = parse_extraction_response(response_text) - - timestamps = [ex.get("timestamp", "") for ex in exchanges if ex.get("timestamp")] - timestamp_start = min(timestamps) if timestamps else "" - timestamp_end = max(timestamps) if timestamps else "" - trace_ids = [ex.get("trace_id", "") for ex in exchanges if ex.get("trace_id")] - trace_refs = ",".join(trace_ids) if trace_ids else "" - - batch_meta = { - "session_id": session_id, - "timestamp_start": timestamp_start, - "timestamp_end": timestamp_end, - } - if trace_refs: - batch_meta["trace_refs"] = trace_refs - - fact_texts = [] - fact_metas = [] - fact_categories = [] - for fact in extracted["facts"]: - fact_texts.append(fact["text"]) - fact_categories.append(fact.get("category", "")) - fact_metas.append({**batch_meta, "source_type": "extraction", "category": fact.get("category", "")}) - - if extracted["summary"]: - fact_texts.append(extracted["summary"]) - fact_categories.append("results") - fact_metas.append({**batch_meta, "source_type": "summary", "category": "results"}) - - for insight in extracted["insights"]: - fact_texts.append(insight["text"]) - fact_categories.append(insight.get("category", "")) - fact_metas.append({**batch_meta, "source_type": "insight", "category": insight.get("category", "")}) - - stored = {"facts": 0, "insights": 0, "summary": 0, "suggestions": 0} - if fact_texts: - # Ask add_entries to hand back the freshly-computed embeddings so we - # don't pay a second embedding round-trip for outlier detection below. - # On API embedders this halves the wall time of memory extraction. - need_embeddings = outlier_sensitivity > 0 - add_result = kb.add_entries( - texts=fact_texts, - collection=EPISODIC_COLLECTION, - metadatas=fact_metas, - return_embeddings=need_embeddings, - ) - stored["facts"] = len(extracted["facts"]) - stored["insights"] = len(extracted["insights"]) - stored["summary"] = 1 if extracted["summary"] else 0 - - if need_embeddings: - if runtime_dir is None: - raise ValueError( - "extract_session: runtime_dir is required when " - "outlier_sensitivity > 0 (centroids/suggestions live there)" - ) - project_dir = runtime_dir - centroids_obj = CategoryCentroids(project_dir / "centroids.json") - queue = SuggestionQueue(project_dir / "suggestions.json") - - suggestion_ids = check_and_queue_outliers( - texts=fact_texts, - categories=fact_categories, - embeddings=add_result["embeddings"], - centroids=centroids_obj, - queue=queue, - threshold=outlier_sensitivity, - session_id=session_id, - ) - stored["suggestions"] = len(suggestion_ids) + Phase 3 builds this over the ``Trace`` pipeline (Tier-0 + mechanical chunk/tag/embed by default, opt-in LLM distillation), + reusing the prompt/parse helpers and outlier detection in this module. + The full signature is kept so ``session.catch_up_extraction`` and tests + keep their call site stable when the pipeline lands. Returns a status + dict reporting that extraction is unavailable; tool-execution indexing + (the other half of catch-up work) is unaffected. + """ + del ( + project_name, + kb, + api_key, + model, + base_url, + provider, + categories, + runtime_dir, + outlier_sensitivity, + mlflow_uri, + exchanges, + ) return { - "status": "ok", - **stored, - "total_entries": len(fact_texts), - "session_id": session_id, + "status": "extraction_unavailable", + "facts": 0, + "insights": 0, + "summary": 0, + "suggestions": 0, + "total_entries": 0, + "session_id": session_id or "", } diff --git a/src/dsagt/observability.py b/src/dsagt/observability.py index 55eb0f2..3711cb8 100644 --- a/src/dsagt/observability.py +++ b/src/dsagt/observability.py @@ -12,10 +12,11 @@ -------------- init_tracing(service_name, *, mlflow_url=None, session_id=None) Configure MLflow's tracer provider once per process. Reads - ``./dsagt_config.yaml`` for ``mlflow.port`` + ``project`` and - ``./.runtime`` for ``session_id``. Raises ``RuntimeError`` when no - MLflow URL is available — processes that legitimately run without - tracing (one-shot setup tools, tests) should not call this function. + ``./.dsagt/config.yaml`` for ``project`` and resolves the tracking URI + serverlessly (``resolve_tracking_uri``: ``MLFLOW_TRACKING_URI`` env → + config → default ``sqlite:////mlflow.db``). Never raises — when cwd + isn't a project dir it logs and no-ops, so one-shot tools / tests that + aren't in a project simply run without tracing. traced(span_name, *, capture=(), extract_return=None) Decorator. Opens a span around a function call, captures named arguments @@ -29,17 +30,10 @@ Process-wide proxy for the *current* span. ``obs.set("hits", 5)`` is a no-op when no span is active, so business code can annotate without branching on whether tracing is on. - -llm_source(source) / llm_call_context(source) - Marker decorator/context for LLM-call origin tagging. Stamps - ``dsagt.source`` (+ ``dsagt.agent``) on traces created inside the block - via MLflow's native ``tracing.context`` API, so the UI's session/source - filters surface every LLM call's origin (extraction, embedding, agent). """ from __future__ import annotations -import asyncio import atexit import functools import inspect @@ -74,20 +68,14 @@ _initialized = False _tracer_provider = None _default_session_id: str | None = None -# Strategy pointers bound at init_tracing time. Both exist to keep +# Strategy pointer bound at init_tracing time. Exists to keep # backend-specific behavior out of call sites: # # _metadata_stamper(dict) # Write key/value metadata to the currently-active trace via MLflow's # InMemoryTraceManager (so it lands in trace_metadata, queryable in # the UI). -# -# _llm_context_factory(dict) → context manager -# Tag every trace created inside the returned context. Uses -# mlflow.tracing.context which stamps at trace-creation time — the only -# reliable window for LLM traces emitted via mlflow.litellm.autolog. _metadata_stamper: "Callable[[dict], None] | None" = None -_llm_context_factory: "Callable[[dict], Any] | None" = None def init_tracing( @@ -98,25 +86,24 @@ def init_tracing( """Install MLflow's tracer provider as the OTel global. Every ``trace.get_tracer(...)`` call (from ``@traced`` / ``child_span`` - in MCP servers, dsagt-run, and the proxy) routes spans into MLflow's - native trace store. This gives ``mlflow.spanInputs`` / ``mlflow.spanOutputs`` + in MCP servers and dsagt-run) routes spans into MLflow's native trace + store. This gives ``mlflow.spanInputs`` / ``mlflow.spanOutputs`` full integration and direct access to ``InMemoryTraceManager`` for trace-metadata stamping (session id, source, agent). - Single source of truth: reads ``./dsagt_config.yaml`` for - ``mlflow.port`` + ``project``; reads ``./.runtime`` for ``session_id``. - No env-var fallback, no parent-walk — every dsagt service runs with - cwd == project_dir by contract. + Serverless: reads ``./.dsagt/config.yaml`` for ``project`` and resolves + the tracking URI via :func:`resolve_tracking_uri` — defaulting to a + ``sqlite:////mlflow.db`` store that needs no running server. The session + id is passed in by the caller (the MCP server mints it at startup via + ``session.append_session``); ``None`` when not supplied. - The ``mlflow_url`` and ``session_id`` keyword args are kept for tests - and proxy mode, where the caller plants known values directly. + The ``mlflow_url`` and ``session_id`` keyword args are kept for tests, + where the caller plants known values directly. - Raises ``RuntimeError`` when no MLflow URL is available. Processes - that legitimately run without tracing (one-shot setup tools, tests) - should not call this function; tests install a test provider directly. + Never raises. When cwd isn't a dsagt project dir, logs and no-ops so + one-shot tools / tests outside a project simply run untraced. """ - global _initialized, _default_session_id - global _metadata_stamper, _llm_context_factory + global _initialized, _default_session_id, _metadata_stamper if _initialized: if session_id: @@ -124,38 +111,24 @@ def init_tracing( return cfg_pdir, cfg = find_project_config() - _default_session_id = session_id or _read_session_id_from_runtime(cfg_pdir) - mlflow_url = mlflow_url or _mlflow_url_from_config(cfg) - - if not mlflow_url: - raise RuntimeError( - f"{service_name}: no observability backend configured. " - f"Expected dsagt_config.yaml with mlflow.port set in cwd or a " - f"parent directory. Processes that legitimately run without " - f"tracing should not call init_tracing at all." - ) - project_name = (cfg or {}).get("project") if not project_name: - raise RuntimeError( - "no 'project' in dsagt_config.yaml — this should never happen " - "for a project created via `dsagt init`." + logger.warning( + "%s: no .dsagt/config.yaml with a 'project' in cwd (%s) — tracing " + "disabled for this process.", + service_name, + Path.cwd(), ) + return + + _default_session_id = session_id + if mlflow_url is None: + resolve_cfg = dict(cfg) + resolve_cfg["project_dir"] = str(cfg_pdir) + mlflow_url = resolve_tracking_uri(resolve_cfg) _install_mlflow_provider(mlflow_url, project_name) _metadata_stamper = _stamp_metadata_mlflow - _llm_context_factory = _mlflow_llm_context - - # Autolog every LiteLLM completion/embedding call from this process into - # MLflow. With MLflow's tracer provider installed, autolog stamps full - # request/response into ``mlflow.spanInputs`` / ``mlflow.spanOutputs`` - # natively. This feeds memory extraction (it queries MLflow for - # session traces) and ``dsagt info``. ``dsagt-run`` doesn't make - # litellm calls, so skip there. - if service_name != "dsagt-run": - import mlflow - - mlflow.litellm.autolog() _initialized = True atexit.register(_shutdown) logger.info( @@ -168,7 +141,7 @@ def init_tracing( def find_project_config() -> tuple[Path | None, dict | None]: - """Read ``./dsagt_config.yaml`` from cwd. + """Read ``./.dsagt/config.yaml`` from cwd. Returns ``(cwd, parsed_config)`` or ``(None, None)`` if cwd isn't a project directory. No walking — services that need this info run @@ -181,7 +154,7 @@ def find_project_config() -> tuple[Path | None, dict | None]: helper is only for services running inside the project. """ cwd = Path.cwd().resolve() - candidate = cwd / "dsagt_config.yaml" + candidate = cwd / ".dsagt" / "config.yaml" if not candidate.exists(): return None, None try: @@ -193,32 +166,35 @@ def find_project_config() -> tuple[Path | None, dict | None]: return cwd, None -def _mlflow_url_from_config(cfg: dict | None) -> str | None: - """Return ``http://localhost:`` from a parsed dsagt_config.yaml.""" - if not cfg: - return None - port = (cfg.get("mlflow") or {}).get("port") - return f"http://localhost:{port}" if port else None - - -def _read_session_id_from_runtime(project_dir: Path | None) -> str | None: - """Read the active session id from ``/.runtime``. - - ``dsagt mlflow`` writes ``session_id`` here at startup; every service - running for the duration of that MLflow daemon shares one session id. +def resolve_tracking_uri(config: dict | None) -> str: + """Resolve the MLflow tracking URI for DSAGT self-logging. Never raises. + + Resolution order: + 1. ``MLFLOW_TRACKING_URI`` env — join the user's own store if they + run one. + 2. ``mlflow.tracking_uri`` in the project config — an explicit + override. + 3. Default ``sqlite:////mlflow.db`` — a serverless + SQLite store that always works with no listener running. + + The MLflow Python client honors a ``sqlite:`` tracking URI directly + (auto-creating + migrating the DB on first use), so self-logging needs + no server and the resolver never has to fail. SQLite is MLflow's + supported serverless backend — the filesystem store (``file:`` / + ``./mlruns``) is deprecated as of Feb 2026. DSAGT emits only traces + (no runs/models), so the experiment's default artifact dir is never + materialized. """ - if project_dir is None: - return None - runtime_file = project_dir / ".runtime" - if not runtime_file.exists(): - return None - try: - import json as _json - - return _json.loads(runtime_file.read_text()).get("session_id") - except Exception as e: - logger.debug("could not read session_id from %s: %s", runtime_file, e) - return None + env_uri = os.environ.get("MLFLOW_TRACKING_URI") + if env_uri: + return env_uri + cfg = config or {} + configured = (cfg.get("mlflow") or {}).get("tracking_uri") + if configured: + return configured + pdir = cfg.get("project_dir") + base = Path(pdir).resolve() if pdir else Path.cwd().resolve() + return f"sqlite:///{base / 'mlflow.db'}" def _install_mlflow_provider(mlflow_url: str, project_name: str) -> None: @@ -250,9 +226,8 @@ def _install_mlflow_provider(mlflow_url: str, project_name: str) -> None: def _stamp_metadata_on_trace(request_id: str, metadata: dict) -> None: """Write metadata to a specific MLflow trace by id. - Used when the caller has the trace_id in hand (e.g. inside the proxy's - ``_DSAGTMlflowLogger`` right after ``start_trace``). ``stamp_metadata`` - is the higher-level version that looks up the current trace via the + Used when the caller has the trace_id in hand. ``stamp_metadata`` is + the higher-level version that looks up the current trace via the active OTel span. """ try: @@ -285,105 +260,6 @@ def stamp_metadata(metadata: dict) -> None: _metadata_stamper(metadata) -def _mlflow_llm_context(metadata: dict): - """MLflow native tracing.context — stamps at trace-creation time. - - Used by ``llm_call_context`` so every LLM trace emitted via - ``mlflow.litellm.autolog`` inside the block lands in MLflow with - ``dsagt.source`` / ``dsagt.agent`` / ``mlflow.trace.session`` already - set on its trace_metadata. - """ - import mlflow - - return mlflow.tracing.context(metadata=metadata) - - -@contextmanager -def llm_call_context(source: str): - """Stamp ``dsagt.source`` (+ ``dsagt.agent``, ``mlflow.trace.session``) - on traces created inside this block. - """ - metadata = {"dsagt.source": source} - if agent := os.environ.get("DSAGT_AGENT"): - metadata["dsagt.agent"] = agent - if _default_session_id: - metadata["mlflow.trace.session"] = _default_session_id - if _llm_context_factory is not None: - with _llm_context_factory(metadata): - yield - else: - yield - - -def llm_source(source: str): - """Decorator form of ``llm_call_context`` for tidy call sites. - - Handles both sync and async. Every LLM call made inside the decorated - function lands in MLflow with ``dsagt.source = `` metadata, - letting the UI distinguish extraction / embedding / agent-turn origins - at a glance. - """ - - def dec(fn): - if asyncio.iscoroutinefunction(fn): - - @functools.wraps(fn) - async def aw(*a, **kw): - with llm_call_context(source): - return await fn(*a, **kw) - - return aw - - @functools.wraps(fn) - def sw(*a, **kw): - with llm_call_context(source): - return fn(*a, **kw) - - return sw - - return dec - - -def extract_cache_stats(usage: dict) -> tuple[int, int]: - """Extract (cache_read, cache_write) tokens from a LiteLLM usage dict. - - LiteLLM's ``Usage`` object doesn't backfill cache fields across - providers; whichever shape the upstream returned is the one populated. - The configured ``LLM_PROVIDER`` (passed to ``dsagt-proxy --provider X`` - and used as the LiteLLM provider prefix in the model_list) decides - which response format we get back. The four field-name conventions - we've seen in practice are all checked here so this function works - regardless of provider config: - - Cache read (tokens served from cache at the lower rate): - - ``cache_read_input_tokens`` — Anthropic, Bedrock-Claude - - ``prompt_tokens_details.cached_tokens`` — OpenAI, Azure-OpenAI - - ``cached_content_token_count`` — Gemini - - ``prompt_cache_hit_tokens`` — DeepSeek - - Cache write (only Anthropic-family bills cache writes separately at - the 1.25× premium; OpenAI/Gemini/DeepSeek auto-cache without a - distinct write fee, so the field doesn't exist): - - ``cache_creation_input_tokens`` — Anthropic, Bedrock-Claude - """ - if not isinstance(usage, dict): - return (0, 0) - - details = usage.get("prompt_tokens_details") or {} - if not isinstance(details, dict): - details = {} - - read = ( - usage.get("cache_read_input_tokens") - or details.get("cached_tokens") - or usage.get("cached_content_token_count") - or usage.get("prompt_cache_hit_tokens") - or 0 - ) - write = usage.get("cache_creation_input_tokens") or 0 - return (int(read), int(write)) - - def _shutdown() -> None: """Flush and shut down the tracer provider. Registered via atexit.""" global _tracer_provider @@ -420,55 +296,6 @@ def open_span(name: str): yield span -def configure_litellm_retries( - num_retries: int = 5, - request_timeout: float = 300.0, -) -> None: - """Configure LiteLLM module-level retry / timeout knobs and quiet its - stdout chatter. - - These are independent of tracing — even ``dsagt-setup-kb`` running before - any project exists (and therefore with no MLflow endpoint) gets the - rate-limit-resilient embedding path. - - LiteLLM's ``num_retries`` retries on transient failures including 429s - with exponential backoff. We also retry inside APIEmbeddingClient with - finer control, but the litellm-level setting acts as a backstop for - other code paths (e.g. the proxy). - - LiteLLM is also chatty by default: every error prints a "Give Feedback / - Get Help" footer to stdout, and every successful call duplicates a log - line through the ``LiteLLM`` logger. Both make ``dsagt-setup-kb`` output - nearly unreadable during long ingests with retries. We suppress them - here so DSAgt's own progress logs are visible. - """ - # litellm[proxy] is a hard dependency in pyproject.toml — failing this - # import means a broken install, not "tracing optional". - import litellm - - litellm.num_retries = num_retries - litellm.request_timeout = request_timeout - - # Kill the "Give Feedback / Get Help" + "Provider List" stdout footers - # that LiteLLM prints on every exception. These are aimed at first-time - # users; for a long-running embed job they make the output unreadable. - litellm.suppress_debug_info = True - - # Stop the duplicate INFO log lines. LiteLLM emits its own - # "Wrapper: Completed Call" line on every embedding call, and propagation - # to the root logger doubles each one (once via LiteLLM's namespace, once - # via root). Mute the namespace and stop propagation. - _llm_log = logging.getLogger("LiteLLM") - _llm_log.setLevel(logging.WARNING) - _llm_log.propagate = False - - logger.debug( - "configure_litellm_retries: num_retries=%d timeout=%.0fs", - num_retries, - request_timeout, - ) - - # --------------------------------------------------------------------------- # obs — process-wide proxy for the current span # --------------------------------------------------------------------------- @@ -652,10 +479,8 @@ def _derive_source(span_name: str | None) -> str | None: """Map a span name to a ``dsagt.source`` value. Prefix-based dispatch so every span we open lands with a source tag - without touching the call site. LLM traces get their source from the - proxy's ``_DSAGTMlflowLogger`` (agent) or ``@llm_source`` (extraction, - embedding) instead — this helper covers the non-LLM spans our own - instrumentation emits. + without touching the call site. Covers the non-LLM spans our own + instrumentation emits (kb / registry / tool). """ if not span_name: return None @@ -755,7 +580,7 @@ def kb_embed_span(backend: str | None, model: str | None, n_texts: int): Used for both query embedding (kb.search) and chunk embedding (kb.ingest, kb.append, kb.add_entries). Backend-agnostic: ``backend`` is ``"api"`` - for LiteLLM/HTTP embedders or ``"local"`` for sentence-transformers. + for the HTTP embedder or ``"local"`` for sentence-transformers. """ return child_span( "kb.embed", @@ -826,8 +651,7 @@ def tool_execute_span(record_id: str, tool_name: str): Instead, every ``tool.execute`` span carries: * ``session.id`` (attached automatically via init_tracing's session_id) - * ``record_id`` — matches the ``tool_use_id`` in the proxy's intent record, - which is how trace_archive correlates execution to LLM intent today. + * ``record_id`` — correlates the execution to its ``trace_archive`` record. * ``tool_name`` — for filtering in the MLflow UI. Filter by ``session.id`` in the MLflow trace view to see all tool @@ -867,357 +691,159 @@ def truncate(value: str, limit: int = 256) -> str: # --------------------------------------------------------------------------- -# Phase 2: dsagt-proxy callback (cache breakpoints + sidechannel detection) -# --------------------------------------------------------------------------- -# -# When ``dsagt start --enable-proxy`` is set, the proxy intercepts every -# LLM call and emits OTLP spans to MLflow's /v1/traces endpoint via -# LiteLLM's built-in "otel" callback. Two extra concerns can't be done -# by pure observation — they require request mutation / response logging -# at the proxy hot path: -# -# 1. Anthropic prompt-cache breakpoint injection (saves money on -# multi-turn sessions; anthropic + bedrock-anthropic honor it, -# others ignore the marker). -# 2. Sidechannel-call detection (agents' hardcoded title-gen / session- -# namer models would 400 against lab gateways; we mock them and -# surface a single warning at teardown so the user can spot a typo -# in their primary llm.model vs. a harmless sidechannel hit). -# -# These live here (not in provenance.py) because they're observation-time -# concerns, gated by proxy mode, and consumed by the proxy entry point -# in commands/proxy_server.py via :func:`init_proxy_tracing`. - -_CACHE_MARKER = {"type": "ephemeral"} - - -def _inject_cache_breakpoints(messages: list, kwargs: dict) -> None: - """Mark the largest stable request prefix as cacheable. - - Anthropic prompt caching keys on the prefix UP TO each marked block, so - one marker at the end of the tools array caches "system + tools", and a - second on the system message itself ensures the system block is cached - even on requests with no tools. Subsequent turns within the 5-minute - TTL pay 10% on the cached prefix instead of 100%. - - Mutates ``messages`` and ``kwargs`` in place — the proxy reads the same - objects on its way to the upstream call. - - Marker is ``{"type": "ephemeral"}``; LiteLLM forwards it as - ``cache_control`` to anthropic-family providers (Anthropic direct, - Bedrock-Claude). Providers without prompt caching (current OpenAI - automatic caching ignores explicit markers, Cohere/Mistral/etc just - drop them) treat it as a no-op, so this is safe to set unconditionally. - """ - # 1) Tools: stamp the last tool definition. In LiteLLM's OpenAI-shape - # the tool dict carries cache_control as a top-level key and the - # Anthropic translator picks it up. - tools = kwargs.get("tools") or [] - if tools and isinstance(tools[-1], dict): - tools[-1]["cache_control"] = _CACHE_MARKER - - # 2) System message: stamp the last text block. For string content we - # promote to block format because cache_control lives on the block, - # not the message itself. - for msg in messages: - if msg.get("role") != "system": - continue - content = msg.get("content") - if isinstance(content, str): - msg["content"] = [ - { - "type": "text", - "text": content, - "cache_control": _CACHE_MARKER, - } - ] - elif isinstance(content, list) and content: - last_block = content[-1] - if isinstance(last_block, dict): - last_block["cache_control"] = _CACHE_MARKER - break - - -# --------------------------------------------------------------------------- -# Sidechannel model-call handling +# Agent-trace sink — CanonicalTrace → MLflow spans (the Phase-2 foreign feed) # --------------------------------------------------------------------------- -# -# Every agent platform hardcodes a small/fast model for internal features -# (goose → gpt-4o-mini session-namer; claude → claude-haiku-4-5... title -# generator; the next agent will pick its own). When the user's gateway -# doesn't carry that exact bare name — which is the norm for lab gateways -# that alias every model — those requests would 400 and clutter MLflow. -# -# Rather than maintain a per-vendor list of known hardcoded names (which -# rots as vendors rename their sidechannel models), DSAGT catches all of -# them with one wildcard LiteLLM route, records which names fired, and -# surfaces a single yellow warning at session teardown so the user can -# distinguish a harmless sidechannel from a typo in their own config. -# -# Callers import the specific thing they need: -# commands/proxy_server._generate_config → SIDECHANNEL_WILDCARD_ROUTE_YAML -# DSAGTCallback.log_success_event → record_sidechannel_call() -# commands/cli._cmd_start (teardown) → print_sidechannel_warning() - -import json as _json # local alias keeps the section self-contained -import sys as _sys -from datetime import datetime as _datetime, timezone as _timezone -from pathlib import Path as _Path - -#: Env var the parent process exports so the proxy's DSAGT callback can tell -#: "this is the configured primary model" from "this is a sidechannel hit". -SIDECHANNEL_PRIMARY_MODEL_ENV = "DSAGT_PRIMARY_MODEL" - -#: JSONL file (one entry per intercepted call) that the proxy subprocess -#: appends to and the parent reads at teardown. Lives at the project -#: directory root, adjacent to ``trace_archive/``. -SIDECHANNEL_LOG_FILENAME = "sidechannel.jsonl" - -#: Canned reply the wildcard returns. Short enough that goose's -#: session-namer (expects ≤4 words) and claude's title generator both -#: accept it without error. -_SIDECHANNEL_CANNED_RESPONSE = "session" - -#: Post-routing model name LiteLLM resolves the wildcard catchall to. Only -#: true mock hits end up here; explicit primary entries and aliases route -#: to the real upstream regardless of what name the client requested. The -#: sidechannel detector uses this to avoid flagging alias hits as -#: sidechannel — see ``record_sidechannel_call``. -SIDECHANNEL_CATCHALL_MODEL = "openai/dsagt-sidechannel-catchall" - -#: Where the user can read the longer explanation. Printed in the warning. -SIDECHANNEL_DOC_LOCATION = "README.md § Sidechannel model calls" - -#: YAML fragment appended to the proxy's ``model_list`` after the primary -#: route. LiteLLM prefers exact matches over wildcards, so the configured -#: model still routes normally; everything else falls through to the mock. -#: -#: ``api_base`` has to be a syntactically valid URL but is never dialed — -#: ``mock_response`` short-circuits upstream entirely. -SIDECHANNEL_WILDCARD_ROUTE_YAML = f"""\ - - model_name: "*" - litellm_params: - model: {SIDECHANNEL_CATCHALL_MODEL} - api_base: http://invalid.local - api_key: unused - mock_response: "{_SIDECHANNEL_CANNED_RESPONSE}" -""" - - -def _sidechannel_client_requested_model(kwargs: dict) -> str | None: - """Return the model name the client sent, not the post-routing target. - - LiteLLM mutates ``kwargs["model"]`` to the resolved route during - completion, so by the time callbacks fire the original name is gone - from there — a wildcard hit's ``kwargs["model"]`` is always the - wildcard's ``litellm_params.model`` target. - ``standard_logging_object.model_group`` preserves the name from the - client's request body. For exact matches it equals the configured - primary; for wildcard hits it's the actual sidechannel name (e.g. - ``gpt-4o-mini``, ``claude-haiku-4-5-20251001``) — which is what the - warning needs to show. - """ - slo = kwargs.get("standard_logging_object") or {} - if isinstance(slo, dict): - grp = slo.get("model_group") - if grp: - return grp.split("/", 1)[-1] - # Fallback: whatever kwargs has. Worse (may be the catchall name) but - # still informative if standard_logging_object isn't populated. - m = kwargs.get("model") or "" - return m.split("/", 1)[-1] or None - - -def record_sidechannel_call(records_dir, kwargs: dict) -> None: - """Append a JSONL entry when a request hit the wildcard catchall. - - Detection rule: ``kwargs["model"]`` (the post-routing target LiteLLM - selected) equals ``SIDECHANNEL_CATCHALL_MODEL``. This is the only - reliable discriminator: name-based comparison against the primary - misclassifies alias hits (e.g. ``claude-sonnet-4-5`` aliased to a - longer lab-gateway model name) as sidechannel even though they - actually routed to the real upstream. - - Called from the DSAGT callback's success handler, so only successful - calls get logged — failures land in MLflow as errors regardless. - No-ops when ``SIDECHANNEL_PRIMARY_MODEL_ENV`` isn't set. - """ - primary = os.environ.get(SIDECHANNEL_PRIMARY_MODEL_ENV) - if not primary: - return +_S_PER_NS = 1e9 - routed_to = kwargs.get("model") or "" - if routed_to != SIDECHANNEL_CATCHALL_MODEL: - return # real upstream call (primary entry or alias) — not a sidechannel - requested = _sidechannel_client_requested_model(kwargs) - if not requested: - return +def _to_ns(epoch_s: float | None) -> int | None: + return int(epoch_s * _S_PER_NS) if epoch_s is not None else None - entry = { - "timestamp": _datetime.now(_timezone.utc).isoformat().replace("+00:00", "Z"), - "model": requested, - "agent": os.environ.get("DSAGT_AGENT", ""), - "session": os.environ.get("DSAGT_SESSION_ID", ""), - } - path = _Path(records_dir).parent / SIDECHANNEL_LOG_FILENAME - try: - path.parent.mkdir(parents=True, exist_ok=True) - with open(path, "a") as f: - f.write(_json.dumps(entry) + "\n") - except Exception as e: - logger.debug("sidechannel log append failed: %s", e) +class MLflowSink: + """Render a :class:`~dsagt.traces.Trace` into MLflow spans (a trace consumer). -def print_sidechannel_warning(project_dir, session_id: str | None) -> None: - """Read ``SIDECHANNEL_LOG_FILENAME`` under *project_dir*, dedup within - *session_id*, and print a yellow warning to stdout. + The agent (foreign-trace) half of observability: where ``init_tracing`` + + ``@traced`` emit DSAGT's own first-party spans live, this replays a finished + transcript's :class:`~dsagt.traces.Trace` after the fact. It uses + ``mlflow.start_span_no_context`` — the only API that accepts an explicit + ``parent_span`` and backdated ``start_time_ns`` — and mirrors the span + conventions of MLflow's own ``claude_code`` autolog so foreign traces render + identically in the Chat UI: an AGENT root, ``llm`` children carrying + ``message.format="anthropic"`` + ``mlflow.chat.tokenUsage``, and + ``tool_`` children. - No-op when nothing was logged for this session (common case). The - warning lists each unique model that hit the wildcard along with the - call count, and points the user at ``SIDECHANNEL_DOC_LOCATION`` for the - two possible causes (harmless sidechannel vs config typo). + A session ``Trace`` carries one AGENT subtree per turn; the sink emits **one + MLflow trace per AGENT root**, matching the per-prompt granularity autolog's + Stop hook produces. MLflow mints its own trace/span ids, so each trace is + tagged ``dsagt.trace_id = :`` (a stable per-turn + idempotency key). - ANSI colors are only emitted when stdout is a TTY — CI logs stay clean. + A *consumer* of :class:`~dsagt.traces.TraceCollector`: ``name`` keys its own + ack file (``.dsagt/trace_acks_mlflow.json``); ``write`` logs the trace. + Spans are plain dicts (see ``traces`` module docstring), so this reads them + directly — no per-object serialization. """ - log_path = _Path(project_dir) / SIDECHANNEL_LOG_FILENAME - if not log_path.exists(): - return - # Dedup by model name within the current session only. The file is - # append-only across runs, so older sessions' entries are still present - # but don't belong in this run's warning. - seen: dict[str, int] = {} - try: - for line in log_path.read_text().splitlines(): - if not line.strip(): - continue - entry = _json.loads(line) - if session_id and entry.get("session") != session_id: - continue - model = entry.get("model") or "" - seen[model] = seen.get(model, 0) + 1 - except (OSError, ValueError): - return + name = "mlflow" - if not seen: - return + def __init__(self, tracking_uri: str, experiment: str): + self._uri = tracking_uri + self._experiment = experiment - tty = _sys.stdout.isatty() - yellow = "\033[33m" if tty else "" - bold = "\033[1m" if tty else "" - reset = "\033[0m" if tty else "" - - print() - print(f"{yellow}{bold} ⚠ Sidechannel model calls intercepted:{reset}") - for model, count in sorted(seen.items(), key=lambda kv: -kv[1]): - s = "s" if count != 1 else "" - print(f"{yellow} {model} ({count} call{s}){reset}") - print(f"{yellow} Two possible causes:{reset}") - print( - f"{yellow} (1) agent sidechannel (e.g. title generator) — safe to ignore{reset}" - ) - print( - f"{yellow} (2) typo in dsagt_config.yaml llm.model — these replies are canned, not real{reset}" - ) - print(f"{yellow} See: {SIDECHANNEL_DOC_LOCATION}{reset}") - - -# --------------------------------------------------------------------------- -# DSAGTCallback — LiteLLM CustomLogger for proxy-mode cache + sidechannel -# --------------------------------------------------------------------------- - - -def _make_dsagt_callback(records_dir): - """Construct a LiteLLM CustomLogger with cache injection + sidechannel. - - Lazy-imports LiteLLM so the rest of observability.py is testable - without it on the import path. Trace transport is via - ``litellm.callbacks = ["otel"]``, configured by :func:`init_proxy_tracing` - — this callback is only for the two intercept-time concerns LiteLLM - autolog can't cover. - """ - from litellm.integrations.custom_logger import CustomLogger - - class DSAGTCallback(CustomLogger): - def __init__(self, records_dir): - self.records_dir = records_dir - - def log_pre_api_call(self, model, messages, kwargs): - _inject_cache_breakpoints(messages, kwargs) - - def log_success_event(self, kwargs, response_obj, start_time, end_time): - record_sidechannel_call(self.records_dir, kwargs) - - async def async_log_success_event( - self, kwargs, response_obj, start_time, end_time - ): - record_sidechannel_call(self.records_dir, kwargs) - - def log_failure_event(self, kwargs, response_obj, start_time, end_time): - logger.warning("LLM call failed: model=%s", kwargs.get("model")) - - async def async_log_failure_event( - self, kwargs, response_obj, start_time, end_time - ): - logger.warning("LLM call failed: model=%s", kwargs.get("model")) - - return DSAGTCallback(records_dir) - - -def init_proxy_tracing( - mlflow_url: str, project: str, session_id: str | None, records_dir -) -> None: - """Configure LiteLLM proxy → MLflow native MlflowLogger transport, plus - install the DSAGT cache + sidechannel callback. - - Installs MLflow's tracer provider (so any span helpers in the proxy - process emit through MLflow), then plants ``_DSAGTMlflowLogger`` (a - subclass of LiteLLM's ``MlflowLogger``) into LiteLLM's logger cache so - the string ``"mlflow"`` in ``success_callback``/``failure_callback`` - routes through it. The subclass stamps ``mlflow.trace.session`` / - ``dsagt.source=agent`` / ``dsagt.agent`` on every proxy-captured trace - in the narrow window between trace creation and export. - - Caller is ``commands/proxy_server.py:main``. ``records_dir`` is the - project's ``trace_archive/`` so the sidechannel jsonl lands adjacent. - """ - global _initialized, _default_session_id - global _metadata_stamper, _llm_context_factory - - _install_mlflow_provider(mlflow_url, project) - _metadata_stamper = _stamp_metadata_mlflow - _llm_context_factory = _mlflow_llm_context - if session_id: - _default_session_id = session_id - _initialized = True + def write(self, trace) -> list[str]: + """Log every turn subtree; return the MLflow trace id of each.""" + import mlflow - # Install our MlflowLogger subclass so trace metadata stamping happens - # in the narrow window between trace creation and export. Lazy import - # to keep the import graph clean (provenance imports observability, - # not the other way round at top level). - from dsagt.provenance import install_mlflow_logger_with_session_tag + mlflow.set_tracking_uri(self._uri) + mlflow.set_experiment(trace.project or self._experiment) - install_mlflow_logger_with_session_tag() + children: dict[str, list] = {} + for span in trace.spans: + if span["parent_id"] is not None: + children.setdefault(span["parent_id"], []).append(span) - import litellm + trace_ids = [] + for root in trace.spans: + if root["parent_id"] is None: + trace_ids.append( + self._emit_subtree(root, children.get(root["span_id"], []), trace) + ) + return trace_ids - # Cache + sidechannel callback (intercept-time concerns). - callback = _make_dsagt_callback(records_dir) - litellm.callbacks = [callback] + def _emit_subtree(self, root, children, trace) -> str: + """Emit one MLflow trace for an AGENT ``root`` and its direct children.""" + import mlflow + from mlflow.entities import SpanType + from mlflow.tracing.constant import ( + SpanAttributeKey, + TokenUsageKey, + TraceMetadataKey, + ) + from mlflow.tracing.trace_manager import InMemoryTraceManager - # Register the built-in "mlflow" callback by NAME, not instance. LiteLLM - # async dispatch resolves names to logger classes via _in_memory_loggers - # at each call; passing a string ensures our pre-seeded subclass is used - # (it passes the isinstance(MlflowLogger) check). - if "mlflow" not in (litellm.success_callback or []): - litellm.success_callback = (litellm.success_callback or []) + ["mlflow"] - if "mlflow" not in (litellm.failure_callback or []): - litellm.failure_callback = (litellm.failure_callback or []) + ["mlflow"] + kind_to_type = { + "AGENT": SpanType.AGENT, + "LLM": SpanType.LLM, + "TOOL": SpanType.TOOL, + "OTHER": SpanType.UNKNOWN, + } + + ml_root = mlflow.start_span_no_context( + name=root["name"], + span_type=kind_to_type[root["kind"]], + inputs={"prompt": root["attributes"].get("prompt", "")}, + start_time_ns=_to_ns(root["start_time"]), + ) - logger.info( - "init_proxy_tracing: service=dsagt-proxy mlflow=%s session=%s", - mlflow_url, - session_id, - ) + for span in children: + if span["kind"] == "LLM": + child = mlflow.start_span_no_context( + name=span["name"], + parent_span=ml_root, + span_type=SpanType.LLM, + start_time_ns=_to_ns(span["start_time"]), + inputs={ + "model": span["model"] or "unknown", + "messages": span["request"], + }, + attributes={ + "model": span["model"] or "unknown", + SpanAttributeKey.MESSAGE_FORMAT: "anthropic", + }, + ) + if span["usage"]: + inp = span["usage"].get("input_tokens") or 0 + out = span["usage"].get("output_tokens") or 0 + child.set_attribute( + SpanAttributeKey.CHAT_USAGE, + { + TokenUsageKey.INPUT_TOKENS: inp, + TokenUsageKey.OUTPUT_TOKENS: out, + TokenUsageKey.TOTAL_TOKENS: inp + out, + }, + ) + child.set_outputs( + { + "type": "message", + "role": "assistant", + "content": span["response"], + } + ) + else: # TOOL / OTHER + child = mlflow.start_span_no_context( + name=span["name"], + parent_span=ml_root, + span_type=kind_to_type[span["kind"]], + start_time_ns=_to_ns(span["start_time"]), + inputs=span["attributes"].get("input", {}), + attributes={ + "tool_name": span["attributes"].get("tool_name"), + "tool_id": span["attributes"].get("tool_id"), + }, + ) + child.set_outputs({"result": span["attributes"].get("result", "")}) + child.end(end_time_ns=_to_ns(span["end_time"])) + + # Trace-level metadata: session correlation + the per-turn canonical id + # (idempotency key) + request/response previews for the trace list. + try: + mgr = InMemoryTraceManager.get_instance() + with mgr.get_trace(ml_root.trace_id) as in_mem: + meta = { + TraceMetadataKey.TRACE_SESSION: trace.session_id, + "dsagt.trace_id": f"{trace.trace_id}:{root['span_id']}", + "dsagt.agent": trace.agent, + } + in_mem.info.trace_metadata = {**in_mem.info.trace_metadata, **meta} + if prompt := root["attributes"].get("prompt"): + in_mem.info.request_preview = str(prompt)[:1000] + if response := root["attributes"].get("response"): + in_mem.info.response_preview = str(response)[:1000] + except Exception as e: # noqa: BLE001 + logger.warning("MLflowSink: could not stamp trace metadata: %s", e) + + ml_root.set_outputs({"response": root["attributes"].get("response", "")}) + ml_root.end(end_time_ns=_to_ns(root["end_time"])) + return ml_root.trace_id diff --git a/src/dsagt/provenance.py b/src/dsagt/provenance.py index 2fc1dc8..38b9798 100644 --- a/src/dsagt/provenance.py +++ b/src/dsagt/provenance.py @@ -13,24 +13,29 @@ **Pipeline reconstruction**: Reads execution records, builds a dependency graph from input/output file overlap, and renders as a bash script or Snakemake workflow. - -LLM-call provenance lives in MLflow (each MCP-server / agent process -autologs LiteLLM calls via ``init_tracing`` post-proxy-removal). """ from __future__ import annotations +import fcntl import json import logging -import os import subprocess import sys import time import uuid +from contextlib import contextmanager from pathlib import Path from datetime import datetime, timezone +from typing import TYPE_CHECKING -from dsagt.knowledge import CollectionRoute, KnowledgeBase +if TYPE_CHECKING: + # Annotation-only (this module has ``from __future__ import annotations``, so + # the hint is a string). Importing KnowledgeBase at runtime would drag the + # whole retrieval module into ``dsagt-run``, which only writes provenance + # records to disk and never touches a KB — the embedding of those records + # happens later, in ``session.run_extraction``. + from dsagt.knowledge import KnowledgeBase logger = logging.getLogger(__name__) @@ -41,11 +46,6 @@ #: Backwards-compat alias. New code should use ``TOOL_USE_COLLECTION``. TOOL_EXECUTIONS_COLLECTION = TOOL_USE_COLLECTION -TOOL_EXECUTIONS_ROUTE = CollectionRoute( - embedding_backend="api", - vector_db="chroma", - description="Indexed tool execution records from trace_archive.", -) # --------------------------------------------------------------------------- @@ -57,7 +57,7 @@ def _resolve_records_dir(explicit: str | None) -> Path: """Determine the records directory. Priority: explicit ``--records-dir`` flag → ``/trace_archive``, - where cwd must contain ``dsagt_config.yaml`` (the project's + where cwd must contain ``.dsagt/config.yaml`` (the project's single-source-of-truth config written by ``dsagt init``). No env-var chain, no walking up the tree — if the agent's cwd isn't the project dir, that's the bug to fix, not something to recover from silently. @@ -65,14 +65,32 @@ def _resolve_records_dir(explicit: str | None) -> Path: if explicit: return Path(explicit) cwd = Path.cwd().resolve() - if not (cwd / "dsagt_config.yaml").exists(): + if not (cwd / ".dsagt" / "config.yaml").exists(): raise ValueError( - f"No dsagt_config.yaml in cwd ({cwd}); pass --records-dir or " + f"No .dsagt/config.yaml in cwd ({cwd}); pass --records-dir or " "run dsagt-run from a project directory." ) return cwd / "trace_archive" +def _current_session_tag_from_cwd() -> str | None: + """Read the current session tag from ``/.dsagt/state.yaml``. + + ``dsagt-run`` runs with cwd == project dir; the MCP server (also a child + of the agent) minted the session into ``state.yaml`` at startup. Lazy + import of ``session`` avoids a circular import (``session`` imports this + module for ``index_trace_archive``). + """ + from dsagt import session + + cwd = Path.cwd().resolve() + cfg = session.read_config_file(cwd) + project = cfg.get("project") + if not project: + return None + return session.current_session_tag(cwd, project) + + def _parse_file_list(raw: str | None) -> list[str]: """Split a comma-separated file list, stripping whitespace.""" if not raw: @@ -94,13 +112,11 @@ def run_and_record( record_id = record_id or uuid.uuid4().hex[:12] if session_id is None: - from dsagt.observability import ( - find_project_config, - _read_session_id_from_runtime, - ) - - pdir, _ = find_project_config() - session_id = _read_session_id_from_runtime(pdir) + # The MCP server mints the session at startup and records it in + # ``.dsagt/state.yaml``; read the current tag from there so this + # tool span buckets with the rest of the session (cwd == project + # dir by contract). ``None`` if no session has been minted yet. + session_id = _current_session_tag_from_cwd() with tool_execute_span(record_id, tool_name): timestamp_start = datetime.now(timezone.utc).isoformat() @@ -286,21 +302,6 @@ def execution_metadata(record: dict) -> dict: return meta -def index_execution_record(record: dict, kb: KnowledgeBase) -> dict: - """Render and store a single tool execution record in the knowledge base.""" - text = render_execution_text(record) - metadata = execution_metadata(record) - - # No ``route=`` — fall through to kb's default route so embedding - # backend follows the project's ``embedding.backend`` config (BYOA - # default = local sentence-transformers, no API call). - return kb.add_entries( - texts=[text], - collection=TOOL_EXECUTIONS_COLLECTION, - metadatas=[metadata], - ) - - def index_trace_archive( trace_dir: Path, kb: KnowledgeBase, @@ -360,6 +361,63 @@ def index_trace_archive( } +class ToolUseIndexer: + """Idempotent, incremental indexer of ``dsagt-run`` records into ``tool_use``. + + The tool-execution counterpart to :class:`~dsagt.trace_scan.TraceScan`: + ``dsagt-run`` writes one JSON record per call to ``trace_archive/``, and each + :meth:`tick` embeds only the records not already indexed — tracked by + ``record_id`` in a persisted ack set — so re-ticks and cross-session + re-reads can never duplicate (the bug the prior cursor-less batch had). + + One primitive, three triggers, all safe to overlap: the MCP-server heartbeat + (current-session freshness), startup catch-up (the previous session's tail), + and the ``reconstruct_pipeline`` tool (index-then-reconstruct, so a pipeline + review reflects the calls just made). An OS file lock around + load→index→save serializes those callers — distinct instances in one + process, or a future cross-process ticker — against the shared ack file. + """ + + def __init__(self, kb: KnowledgeBase, project_dir: str | Path): + self._kb = kb + pdir = Path(project_dir) + self._trace_dir = pdir / "trace_archive" + self._acks_path = pdir / ".dsagt" / "tool_use_acks.json" + + def _load_acks(self) -> set[str]: + try: + return set(json.loads(self._acks_path.read_text())) + except FileNotFoundError: + return set() + + def _save_acks(self, acks: set[str]) -> None: + self._acks_path.parent.mkdir(parents=True, exist_ok=True) + self._acks_path.write_text(json.dumps(sorted(acks))) + + @contextmanager + def _lock(self): + self._acks_path.parent.mkdir(parents=True, exist_ok=True) + lock_path = self._acks_path.with_suffix(".lock") + with open(lock_path, "w") as lf: + fcntl.flock(lf, fcntl.LOCK_EX) + try: + yield + finally: + fcntl.flock(lf, fcntl.LOCK_UN) + + def tick(self) -> int: + """Index newly-arrived records; return how many were indexed this tick.""" + with self._lock(): + acks = self._load_acks() + before = len(acks) + # index_trace_archive skips record_ids already in ``acks`` and adds + # the newly-indexed ones to it (mutates the set we pass). + result = index_trace_archive(self._trace_dir, self._kb, indexed_ids=acks) + if len(acks) != before: + self._save_acks(acks) + return result.get("indexed", 0) + + # --------------------------------------------------------------------------- # Pipeline reconstruction # --------------------------------------------------------------------------- @@ -528,115 +586,3 @@ def reconstruct_pipeline( if fmt == "snakemake": return render_snakemake(records, deps) return render_bash(records, deps) - - -# --------------------------------------------------------------------------- -# Proxy-mode MLflow logger (LiteLLM callback subclass) -# --------------------------------------------------------------------------- - - -def install_mlflow_logger_with_session_tag() -> None: - """Inject a ``MlflowLogger`` subclass that stamps ``mlflow.trace.session``. - - Why a subclass instead of post-hoc tagging from another callback: - LiteLLM's MlflowLogger does the entire trace lifecycle (start_trace → - set_attributes → end_trace) inside one ``_handle_success`` call. By - the time any sibling success-callback fires, the trace is already - exported and ``mlflow.update_current_trace`` has nothing to update. - Subclassing lets us slot the metadata write between trace creation and - trace export, where the in-memory trace still exists and is mutable. - - Why register via LiteLLM's ``_in_memory_loggers`` cache: when the - proxy resolves the string ``"mlflow"`` from ``success_callback``, it - iterates ``_in_memory_loggers`` looking for any - ``isinstance(_, MlflowLogger)`` and returns it instead of constructing - a fresh one. A subclass passes the isinstance check, so pre-seeding - our subclass means the existing string-based registration in - ``success_callback``/``failure_callback`` automatically routes through - us — no sync-vs-async dispatch quirks to worry about. - - Idempotent: safe to call more than once. - """ - from litellm.integrations.mlflow import MlflowLogger - from litellm.litellm_core_utils import litellm_logging as _ll - - from dsagt.observability import _stamp_metadata_on_trace, extract_cache_stats - - class _DSAGTMlflowLogger(MlflowLogger): - def _start_span_or_trace(self, kwargs, start_time): - span = super()._start_span_or_trace(kwargs, start_time) - # span.request_id is MLflow's trace_id; the trace lives in - # InMemoryTraceManager until the parent _handle_success calls - # _end_span_or_trace, so we have a window here to mutate - # trace_metadata before export. - # - # Agent-turn LLM calls land here (proxy path). Stamp: - # mlflow.trace.session — session grouping in the UI - # dsagt.source=agent — distinguishes from extraction/embedding - # dsagt.agent — which platform (goose, claude, ...) - # Non-agent LLM calls (memory extraction, embeddings) go through - # llm_source(...) decorators and never touch this subclass, so - # hard-coding source="agent" here is safe. - if span is None: - return span - metadata: dict[str, str] = {"dsagt.source": "agent"} - if session_id := os.environ.get("DSAGT_SESSION_ID"): - metadata["mlflow.trace.session"] = session_id - if agent := os.environ.get("DSAGT_AGENT"): - metadata["dsagt.agent"] = agent - _stamp_metadata_on_trace(span.request_id, metadata) - return span - - def _extract_and_set_chat_attributes(self, span, kwargs, response_obj): - # Last window to stamp cache stats before _end_span_or_trace - # exports the trace. - super()._extract_and_set_chat_attributes(span, kwargs, response_obj) - if span is None: - return - usage = (_response_to_usage(response_obj) or {}).get("usage") or {} - read, write = extract_cache_stats(usage) - if not read and not write: - return - _stamp_metadata_on_trace( - span.request_id, - { - "dsagt.cache.read_tokens": str(read), - "dsagt.cache.write_tokens": str(write), - }, - ) - - # Already installed? Leave it. - for cb in _ll._in_memory_loggers: - if type(cb).__name__ == "_DSAGTMlflowLogger": - return - # Drop any vanilla MlflowLogger that beat us to the cache. - _ll._in_memory_loggers[:] = [ - cb - for cb in _ll._in_memory_loggers - if not ( - isinstance(cb, MlflowLogger) and type(cb).__name__ != "_DSAGTMlflowLogger" - ) - ] - _ll._in_memory_loggers.append(_DSAGTMlflowLogger()) - - -def _response_to_usage(response_obj) -> dict | None: - """Best-effort extraction of the ``usage`` dict from a litellm response. - - LiteLLM responses can be dataclass-like (``model_dump()``), pydantic - models (``dict()``), or already plain dicts. We try each, and fall - back to ``None`` if nothing yields a usable shape. - """ - if response_obj is None: - return None - if isinstance(response_obj, dict): - return response_obj - for method in ("model_dump", "dict"): - if hasattr(response_obj, method): - try: - d = getattr(response_obj, method)() - if isinstance(d, dict): - return d - except Exception: - continue - return None diff --git a/src/dsagt/registry.py b/src/dsagt/registry.py index 967a7c9..e7f7aad 100644 --- a/src/dsagt/registry.py +++ b/src/dsagt/registry.py @@ -17,12 +17,20 @@ `search_registry` (tools) and `search_skills` (skills) MCP tools. """ +from __future__ import annotations + import logging from pathlib import Path +from typing import TYPE_CHECKING import yaml -from dsagt.knowledge import KnowledgeBase +if TYPE_CHECKING: + # Annotation-only. A runtime import would pull the whole retrieval module + # into anything that touches the registry — including ``dsagt-run`` via the + # package ``__init__`` — even though the registry only ever holds an + # injected KB instance, never references the class. + from dsagt.knowledge import KnowledgeBase logger = logging.getLogger(__name__) @@ -122,7 +130,10 @@ def _parse_frontmatter(path: Path) -> dict: try: return yaml.safe_load(parts[1]) or {} except yaml.YAMLError as e: - logger.warning( + # Benign: the frontmatter is flat ``key: value`` but not strict YAML; + # we recover the fields below. DEBUG, not WARNING — nothing is lost + # and it's pure noise during ``dsagt init`` catalog indexing. + logger.debug( "Frontmatter in %s isn't strict YAML (%s); recovering flat fields.", path, str(e).splitlines()[0], @@ -274,7 +285,7 @@ class ToolRegistry: * **Bundled tools** ship with the dsagt package at ``_PACKAGE_TOOLS_DIR``. They are read-only; their KB embeddings live in the shared ``bundled_tools`` collection (built once per - machine per dsagt version by ``dsagt setup-kb``). Never copied + machine per dsagt version by ``dsagt init``). Never copied into projects, so package upgrades automatically reach all existing projects. * **Project tools** are agent-saved or user-edited specs in @@ -452,8 +463,8 @@ def reindex_all(self) -> int: """Reindex project-local tool files into the ``tools`` collection. Returns count indexed. Bundled tools are NOT indexed here — they - live in the shared ``tools`` collection built by ``dsagt setup-kb`` - and copied into the project at ``dsagt init`` time. Search via + live in the shared ``tools`` collection built and copied into the + project at ``dsagt init`` time. Search via ``search_registry`` queries the merged collection. """ if not self._kb: diff --git a/src/dsagt/session.py b/src/dsagt/session.py index 0679c2f..a336fd8 100644 --- a/src/dsagt/session.py +++ b/src/dsagt/session.py @@ -3,30 +3,27 @@ Projects are registered in ~/dsagt-projects/projects.yaml (name → absolute path). Default project location is ~/dsagt-projects//. Shared bundled-content -KB lives alongside at ~/dsagt-projects/kb_index/ (built by dsagt setup-kb). +KB lives alongside at ~/dsagt-projects/kb_index/ (provisioned by dsagt init). Project directory layout:: / - dsagt_config.yaml # project configuration + .dsagt/ + config.yaml # project configuration (MCP-server object settings) + state.yaml # session log + memory cursor (owned by the MCP server) + explicit_memories.yaml, suggestions.json, ... # explicit memory trace_archive/ # tool execution records - mlflow/ # MLflow data + mlflow.db # serverless MLflow SQLite trace store (created lazily) tools/ # registered CLI tools tools/code/ # agent-written tool scripts skills/ # instruction-based agent skills kb_index/ # knowledge base collections """ -import json import logging import os import re import shutil -import signal -import socket -import subprocess -import sys -import time from datetime import datetime, timezone from pathlib import Path @@ -34,7 +31,7 @@ from dsagt.memory import extract_session from dsagt.knowledge import KnowledgeBase -from dsagt.provenance import index_trace_archive +from dsagt.provenance import ToolUseIndexer logger = logging.getLogger(__name__) @@ -43,83 +40,78 @@ # Constants # --------------------------------------------------------------------------- -VALID_AGENTS = ("claude", "goose", "roo", "cline", "codex", "opencode") -VALID_MLFLOW_BACKENDS = ("sqlite", "flat-file") +VALID_AGENTS = ("claude", "goose", "cline", "codex", "opencode") DEFAULT_PROJECTS_BASE = Path.home() / "dsagt-projects" # Registry + shared KB live alongside projects under one visible tree — # ``~/dsagt-projects/projects.yaml`` (name → path) and -# ``~/dsagt-projects/kb_index/`` (shared bundled-content KB built by -# ``dsagt setup-kb``). Migrated from ``~/.dsagt/`` on 2026-05-07. +# ``~/dsagt-projects/kb_index/`` (shared bundled-content KB provisioned by +# ``dsagt init``). Migrated from ``~/.dsagt/`` on 2026-05-07. REGISTRY_DIR = DEFAULT_PROJECTS_BASE REGISTRY_FILE = REGISTRY_DIR / "projects.yaml" RESERVED_PROJECT_NAMES = ("projects.yaml", "kb_index", ".skill_sources") +# Per-project dsagt state lives under a hidden ``.dsagt/`` dir (alongside +# explicit memory): ``config.yaml`` (the MCP-server object settings the user +# chose at ``dsagt init``) and ``state.yaml`` (session log + memory cursor, +# owned by the MCP server). +CONFIG_DIRNAME = ".dsagt" +CONFIG_FILENAME = "config.yaml" +STATE_FILENAME = "state.yaml" + + +def config_path(pdir: Path) -> Path: + """Path to a project's ``.dsagt/config.yaml``.""" + return Path(pdir) / CONFIG_DIRNAME / CONFIG_FILENAME + + +def state_path(pdir: Path) -> Path: + """Path to a project's ``.dsagt/state.yaml``.""" + return Path(pdir) / CONFIG_DIRNAME / STATE_FILENAME + + +# Code defaults backfilled into a project's config on read (``_deep_merge``). +# These are NOT user choices and so are NOT written to ``.dsagt/config.yaml`` +# nor prompted at ``dsagt init`` — they live here as the single source of +# truth and are filled in for the MCP server / KB. Embedding is local-only +# for now (BYOA, no credentials); an ``api`` backend can be re-introduced as +# an init choice if it's ever requested. ``chunk_size`` / ``rerank`` default +# in :class:`~dsagt.knowledge.KnowledgeBase`; ``skills.populate_native`` in +# :meth:`AgentSetup.setup_skills`. DEFAULTS = { - # ``llm`` block uses ${VAR} placeholders so per-project config - # references the user's shell env at resolve time. In BYOA mode - # (the default), agent_env's proxy_port gate at agents/__init__.py - # short-circuits the env_overrides call, so this block sits dormant - # — no agent env-var leaks. In proxy mode (--enable-proxy), - # env_overrides reads these values to translate into per-agent env - # vars and proxy_server.py reads them to render its YAML. - "llm": { - "provider": "${LLM_PROVIDER}", - "model": "${LLM_MODEL}", - "base_url": "${LLM_BASE_URL}", - "api_key": "${LLM_API_KEY}", - }, "embedding": { - # Default backend: "local" — sentence-transformers, CPU-side, no - # API credentials needed. Switch to "api" to route through - # litellm to a hosted endpoint (then fill in base_url / api_key - # below and pick a model name your endpoint serves). - # Local default is the HuggingFace identifier ``LocalEmbeddingClient`` - # downloads; user can override with any sentence-transformers - # repo (e.g. ``BAAI/bge-large-en-v1.5`` for higher quality). "backend": "local", "model": "BAAI/bge-small-en-v1.5", "base_url": "", }, - "mlflow": { - "backend": "sqlite", - }, - "categories": { - "quality_control": "Assessment or filtering of data quality, QC metrics, thresholds, pass/fail rates", - "data_management": "File organization, data movement, format conversion, naming conventions", - "transformation": "Data processing steps, parameter choices, pipeline stage configuration", - "assembly": "Genome assembly, contig generation, scaffolding, assembly QC metrics", - "configuration": "Tool settings, environment setup, resource allocation decisions", - "performance": "Runtime, memory usage, throughput, resource consumption observations", - "tool_usage": "Tool selection rationale, parameter tuning, tool-specific behaviors or quirks", - "results": "Output summaries, key findings, deliverables produced", - }, - "extraction": { - "threshold": 0, - "outlier_sensitivity": 0.0, - }, - "knowledge": { - "chunk_size": 1024, - "vector_db": "chroma", - "rerank": False, - }, # External agent-skill catalogs. ``sources`` are GitHub repos whose # SKILL.md skills get indexed into per-source catalog collections for - # ``search_skills`` (searchable but NOT loaded into agent context). - # The agent installs a chosen one via the ``install_skill`` MCP tool; - # the agent setup then mirrors installed + bundled skills into the - # platform's native skill dir (e.g. ``.claude/skills/``). + # ``search_skills``. This is the default when a project picks none. "skills": { "sources": [ { - "name": "scientific", - "url": "https://github.com/K-Dense-AI/scientific-agent-skills", + "name": "genesis", + "url": "https://gitlab.osti.gov/genesis/genesis-skills", "branch": "main", "subdir": "skills", }, ], - "populate_catalog": True, # index sources into the catalog at setup-kb - "populate_native": True, # mirror installed+bundled into .claude/skills + }, + # Episodic memory (Phase 3): the heartbeat's MemoryExtractor subscriber. + # ``enabled`` is a compute/storage opt-in (Tier-0 needs no credentials); + # ``judge`` selects the Tier-1 distillation backend (``local`` = LocalJudge, + # the no-API-key default — left empty/disabled until Tier-1 is wired on). + # ``domain_tags`` are the user's project-specific tags, merged onto the + # stock taxonomy. ``outlier_sensitivity`` 0 disables novelty queueing. + "episodic": { + "enabled": False, + "judge": {"backend": "", "model": ""}, + "domain_tags": {}, + "outlier_sensitivity": 0.0, + # Recency weighting for session_memory retrieval: a newer fact edges out + # a stale one without contradiction detection. Half-life in days (a + # *boost*, never a penalty — durable old facts keep full relevance). + "recency_half_life_days": 14, }, } @@ -153,31 +145,54 @@ def _deep_merge(defaults: dict, overrides: dict) -> dict: return result -def default_config_content( +def build_config( project_name: str, agent: str, - mlflow_port: int, -) -> str: - """Generate the internal dsagt_config.yaml for a new project. - - Internal-only: users don't edit this. Holds the project's - embedding / mlflow / knowledge / extraction settings plus the - pinned MLflow port so MCP servers (started fresh per agent run) - know where to log without relying on shell-env inheritance. - - User credentials are NOT here — the agent reads them from the - user's shell env directly (ANTHROPIC_API_KEY, OPENAI_API_KEY, etc). + *, + knowledge: dict | None = None, + skills: dict | None = None, + episodic: dict | None = None, +) -> dict: + """Assemble a project's ``.dsagt/config.yaml`` body. + + The schema is a strict 1:1 mirror of the choices ``dsagt init`` offers — + every key here corresponds to an init prompt and vice versa: + + - ``project`` / ``agent`` — identity (agent + name/location are prompted). + - ``knowledge.collections`` — the packaged document collections chosen + (default none; the bundled ``tools`` collection is always provisioned + and is not a per-project choice). + - ``skills.sources`` — the skill-catalog repos chosen. + - ``episodic`` — written *only when the user opted in* (it's an opt-in, so a + disabled project stays minimal and backfills ``enabled: false`` on read). + + Everything else (embedding backend, chunk_size, rerank, populate_native) + is a code default backfilled on read — NOT a written choice. Credentials + are never here (shell env only); no MLflow port (serverless sqlite store). """ - body: dict = { + body = { "project": project_name, "agent": agent, - "mlflow": {**DEFAULTS["mlflow"], "port": mlflow_port}, - "embedding": dict(DEFAULTS["embedding"]), - "knowledge": DEFAULTS["knowledge"], - "categories": DEFAULTS["categories"], - "extraction": DEFAULTS["extraction"], - "skills": DEFAULTS["skills"], + "knowledge": knowledge or {"collections": []}, + "skills": skills or {"sources": list(DEFAULTS["skills"]["sources"])}, } + if episodic: + body["episodic"] = episodic + return body + + +def default_config_content( + project_name: str, + agent: str, + *, + knowledge: dict | None = None, + skills: dict | None = None, + episodic: dict | None = None, +) -> str: + """Serialize :func:`build_config` to YAML for ``.dsagt/config.yaml``.""" + body = build_config( + project_name, agent, knowledge=knowledge, skills=skills, episodic=episodic + ) return yaml.dump(body, default_flow_style=False, sort_keys=False) @@ -221,24 +236,37 @@ def kb_from_config(config: dict, index_dir: Path | None = None) -> "KnowledgeBas pdir = Path(config["project_dir"]) emb = config.get("embedding", {}) backend = emb.get("backend", "local") + recency = _recency_half_life(config) if backend == "local": model = emb.get("model") if model and "/" not in str(model): model = None - embedder_kwargs = {"model": model} - else: - embedder_kwargs = { - "model": emb.get("model"), - "base_url": emb.get("base_url"), - "api_key": os.environ.get("EMBEDDING_API_KEY", ""), - } + return KnowledgeBase( + index_dir=index_dir or (pdir / "kb_index"), + default_embedder=backend, + model=model, + recency_half_life_days=recency, + ) return KnowledgeBase( index_dir=index_dir or (pdir / "kb_index"), default_embedder=backend, - embedder_kwargs=embedder_kwargs, + model=emb.get("model"), + base_url=emb.get("base_url"), + api_key=os.environ.get("EMBEDDING_API_KEY", ""), + recency_half_life_days=recency, ) +def _recency_half_life(config: dict) -> float | None: + """Episodic recency half-life (days) when enabled, else ``None`` (off). + + Recency weighting only matters for ``session_memory``, which only has + content when episodic memory is enabled — so it's gated on that opt-in. + """ + epi = config.get("episodic", {}) or {} + return epi.get("recency_half_life_days") if epi.get("enabled") else None + + def project_dir(name: str) -> Path: """Resolve a project name to its directory via the registry.""" registry = _load_registry() @@ -264,12 +292,12 @@ def load_config(project_name: str) -> dict: resolved config dict with defaults applied and 'project_dir' injected. """ pdir = project_dir(project_name) - config_path = pdir / "dsagt_config.yaml" + cfg_file = config_path(pdir) - if not config_path.exists(): - raise FileNotFoundError(f"Config not found: {config_path}") + if not cfg_file.exists(): + raise FileNotFoundError(f"Config not found: {cfg_file}") - raw = yaml.safe_load(config_path.read_text()) or {} + raw = yaml.safe_load(cfg_file.read_text()) or {} config = _deep_merge(DEFAULTS, raw) config = resolve_env_vars(config) @@ -279,22 +307,111 @@ def load_config(project_name: str) -> dict: return config +def read_config_file(pdir: Path) -> dict: + """Read a project's raw ``.dsagt/config.yaml`` by path (no registry, no + defaults merge). Returns ``{}`` if absent — used to prefill the + re-run ``dsagt init`` dialogue with current values. + """ + cfg_file = config_path(pdir) + if not cfg_file.exists(): + return {} + return yaml.safe_load(cfg_file.read_text()) or {} + + +def write_config_file(pdir: Path, config: dict) -> None: + """Write a project's ``.dsagt/config.yaml`` (creates ``.dsagt/`` if + needed). ``config`` is the trimmed schema from :func:`build_config`. + """ + cfg_file = config_path(pdir) + cfg_file.parent.mkdir(parents=True, exist_ok=True) + cfg_file.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False)) + + def _validate(config: dict) -> None: """Validate required fields and values.""" if not config.get("project"): - raise ValueError("'project' is required in dsagt_config.yaml") + raise ValueError("'project' is required in .dsagt/config.yaml") agent = config.get("agent") if not agent: - raise ValueError("'agent' is required in dsagt_config.yaml") + raise ValueError("'agent' is required in .dsagt/config.yaml") if agent not in VALID_AGENTS: raise ValueError(f"'agent' must be one of {VALID_AGENTS}, got '{agent}'") - backend = config.get("mlflow", {}).get("backend") - if backend and backend not in VALID_MLFLOW_BACKENDS: - raise ValueError( - f"'mlflow.backend' must be one of {VALID_MLFLOW_BACKENDS}, got '{backend}'" - ) + +# --------------------------------------------------------------------------- +# Session state (`.dsagt/state.yaml`) — owned by the MCP server +# --------------------------------------------------------------------------- + + +def _empty_state() -> dict: + return {"sessions": [], "memory_cursor": {}} + + +def read_state(pdir: Path) -> dict: + """Read ``.dsagt/state.yaml``; return an empty skeleton if absent.""" + sp = state_path(pdir) + if not sp.exists(): + return _empty_state() + return yaml.safe_load(sp.read_text()) or _empty_state() + + +def write_state(pdir: Path, state: dict) -> None: + """Write ``.dsagt/state.yaml`` (creates ``.dsagt/`` if needed).""" + sp = state_path(pdir) + sp.parent.mkdir(parents=True, exist_ok=True) + sp.write_text(yaml.dump(state, default_flow_style=False, sort_keys=False)) + + +def append_session(pdir: Path) -> dict: + """Append a new session entry and return it. + + Called by the MCP server at startup — the server owns session-id + minting now (not ``dsagt start``), so a bare-launched agent gets a + session id too. Id is a monotonic per-project counter. + """ + state = read_state(pdir) + sessions = state.setdefault("sessions", []) + next_id = max((s.get("id", 0) for s in sessions), default=0) + 1 + entry = { + "id": next_id, + "started_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + } + sessions.append(entry) + state.setdefault("memory_cursor", {}) + write_state(pdir, state) + return entry + + +def current_session(pdir: Path) -> dict | None: + """The most recent session entry, or ``None`` if no session has run.""" + sessions = read_state(pdir).get("sessions") or [] + return sessions[-1] if sessions else None + + +def session_tag(project: str, session_id: int) -> str: + """The trace-correlation tag for a session: ``-``.""" + return f"{project}-{session_id}" + + +def current_session_tag(pdir: Path, project: str) -> str | None: + """The trace tag of the current session, or ``None`` if none exists. + + Used by ``dsagt-run`` to tag tool spans with the same session the MCP + server minted — read from ``state.yaml`` instead of a ``DSAGT_SESSION_ID`` + env var. + """ + cur = current_session(pdir) + if cur is None: + return None + return session_tag(project, cur["id"]) + + +def update_cursor(pdir: Path, **fields) -> None: + """Merge fields into ``state.yaml``'s ``memory_cursor`` map.""" + state = read_state(pdir) + state.setdefault("memory_cursor", {}).update(fields) + write_state(pdir, state) # --------------------------------------------------------------------------- @@ -305,19 +422,22 @@ def _validate(config: dict) -> None: def _collection_exists(path: Path) -> bool: """Return True if *path* looks like a persisted KB collection directory. - Accepts FAISS indexes, ChromaDB-in-a-dir layouts, routed external - collections, and bare ChromaDB sqlite files (as produced by - ``dsagt setup-kb`` for description-only collections). + Accepts ChromaDB-in-a-dir layouts, routed external collections, and + bare ChromaDB sqlite files (as produced by the KB asset build for + description-only collections). """ return path.is_dir() and ( - (path / "index.faiss").exists() - or (path / "chroma_ids.json").exists() + (path / "chroma_ids.json").exists() or (path / "route.json").exists() or (path / "chroma.sqlite3").exists() ) -def setup_runtime_kb(base_index_dir: Path, runtime_dir: Path) -> Path: +def setup_runtime_kb( + base_index_dir: Path, + runtime_dir: Path, + collections: list[str] | None = None, +) -> Path: """Copy base KB collections into a project's kb_index directory. Creates ``/kb_index`` if missing. For each collection @@ -325,9 +445,15 @@ def setup_runtime_kb(base_index_dir: Path, runtime_dir: Path) -> Path: twin doesn't already exist, **copies** (not symlinks) the entire collection directory into the project's kb_index. + *collections*, when given, is an allowlist of collection-directory + names to copy — so a project gets exactly its requested asset set even + when the shared KB holds more (e.g. heavy collections another project + installed). ``None`` copies every populated collection (legacy + copy-everything behavior). + Why copy instead of symlink: different projects on the same machine may run different dsagt versions, and a symlink would let one - project's ``dsagt setup-kb --rebuild`` mutate every project's view + project's KB rebuild mutate every project's view of bundled content. A copy pins each project to whatever bundled content was current when the project first ran. @@ -340,7 +466,10 @@ def setup_runtime_kb(base_index_dir: Path, runtime_dir: Path) -> Path: if not base_index_dir.exists(): return runtime_kb_dir + allow = set(collections) if collections is not None else None for collection_dir in base_index_dir.iterdir(): + if allow is not None and collection_dir.name not in allow: + continue if not _collection_exists(collection_dir): continue dest = runtime_kb_dir / collection_dir.name @@ -353,21 +482,119 @@ def setup_runtime_kb(base_index_dir: Path, runtime_dir: Path) -> Path: return runtime_kb_dir +def _provision_kb( + pdir: Path, + include: list[str] | None, + exclude: list[str] | None, + embedding: dict | None = None, +) -> None: + """Build the requested KB assets into the shared cache, then copy that + set into the project. + + The first project on a machine pays the one-time build (bundled tools + + genesis catalog by default); later projects just copy. The copy is + scoped to the requested set, so a project gets exactly what was asked + for regardless of what else the shared cache holds. + + Best-effort: a build failure (offline, no embedding model) degrades to + an empty-but-valid project KB with a warning rather than aborting + ``dsagt init`` — the build retries on a later ``dsagt init``. + """ + from dsagt.commands.setup_core_kb import ( + asset_collection_name, + ensure_assets, + resolve_assets, + ) + + assets = resolve_assets(include=include, exclude=exclude) + if not assets: + # ``--exclude all``: a valid project with an empty KB. + (pdir / "kb_index").mkdir(parents=True, exist_ok=True) + return + + shared = REGISTRY_DIR / "kb_index" + first_ever = not shared.exists() or not any( + _collection_exists(c) for c in shared.iterdir() + ) + # Which requested assets aren't in the shared cache yet — i.e. what this + # init will actually build (and narrate). Empty → silent fast path. + pending = [ + a for a in assets if not _collection_exists(shared / asset_collection_name(a)) + ] + if pending: + if first_ever: + print( + "Performing initial dsagt setup — first project on this " + "machine (one-time, may take a few minutes):", + flush=True, + ) + else: + print("Provisioning knowledge base assets:", flush=True) + + emb = embedding or DEFAULTS["embedding"] + backend = emb.get("backend", "local") + if backend == "local": + embedder_kwargs = {"model": emb.get("model")} + else: + embedder_kwargs = { + "model": emb.get("model"), + "base_url": emb.get("base_url"), + "api_key": os.environ.get("EMBEDDING_API_KEY", ""), + } + + try: + ensure_assets( + assets, + shared, + embedding_backend=backend, + embedder_kwargs=embedder_kwargs, + ) + except Exception as e: # noqa: BLE001 — never let a build failure block init + print( + f" Warning: could not build knowledge base ({e}). The project " + "works without it; re-run `dsagt init` for a project once a " + "network / embedding backend is available to install the shared " + "core KB.", + flush=True, + ) + + wanted = [asset_collection_name(a) for a in assets] + setup_runtime_kb(shared, pdir, collections=wanted) + + if pending: + print(" Knowledge base ready.", flush=True) + + def init_project( project_name: str, agent: str, - mlflow_port: int | None = None, location: Path | None = None, -) -> tuple[Path, int]: - """Create a new project directory with default config and subdirectories. - - BYOA model: at init we lay down everything the user needs to point - their own agent process at our MCP servers. Picks (or honors) an - MLflow port and writes it to the internal ``dsagt_config.yaml`` so - later ``dsagt mlflow `` and the MCP-server children all - agree on where traces land. - - Returns ``(project_dir, mlflow_port)``. + include: list[str] | None = None, + exclude: list[str] | None = None, + *, + embedding: dict | None = None, + knowledge: dict | None = None, + skills: dict | None = None, + episodic: dict | None = None, +) -> Path: + """Create or reconfigure a project — ``dsagt init`` is re-runnable. + + BYOA model: we lay down everything the user needs to point their own + agent process at our MCP server. The trace store is serverless + (``sqlite:////mlflow.db``), so there's no port to pick — the + MCP-server children resolve the store from the project dir. + + Idempotent: on a project that already has ``.dsagt/config.yaml`` this + overwrites the config with the new choices and provisions any + newly-requested KB assets. It never deletes agent-saved data; the + caller (``dsagt init`` in ``cli.py``) handles destructive deltas + (agent switch, removed collections) with explicit per-change prompts. + + Knowledge base: provisioned with a chosen set of KB assets + (``include`` / ``exclude``, default = bundled tools + genesis catalog), + built once into the shared ``~/dsagt-projects/kb_index/`` and copied in. + + Returns the project directory. """ if agent not in VALID_AGENTS: raise ValueError(f"agent must be one of {VALID_AGENTS}, got '{agent}'") @@ -380,69 +607,40 @@ def init_project( pdir = (location or DEFAULT_PROJECTS_BASE) / project_name - if (pdir / "dsagt_config.yaml").exists(): - raise FileExistsError(f"Project already exists: {pdir}") - pdir.mkdir(parents=True, exist_ok=True) # `tools/` and `tools/code/` are created by ToolRegistry on first server # startup so bundled tools get copied in (it short-circuits if tools/ - # already exists). - for subdir in ("trace_archive", "mlflow", "skills", ".dsagt"): + # already exists). ``mlflow.db`` is created lazily by the MLflow client + # on first span. + for subdir in ("trace_archive", "skills", CONFIG_DIRNAME): (pdir / subdir).mkdir(parents=True, exist_ok=True) - setup_runtime_kb(REGISTRY_DIR / "kb_index", pdir) + _provision_kb(pdir, include, exclude, embedding=embedding) - # If the shared KB hasn't been built yet, warn — the project's - # kb_index/ will be empty until ``dsagt setup-kb`` runs. Don't - # rebuild here: that conflicts with the contract that ``dsagt - # init`` does no embedding work. - shared_kb = REGISTRY_DIR / "kb_index" - if not shared_kb.exists() or not any( - _collection_exists(c) for c in shared_kb.iterdir() - ): - print( - f" Warning: shared KB at {shared_kb} is empty — " - "run `dsagt setup-kb` to build bundled tools and skills " - "before launching your agent.", - flush=True, - ) - - if mlflow_port is None: - mlflow_port = pick_free_port() - - (pdir / "dsagt_config.yaml").write_text( - default_config_content(project_name, agent, mlflow_port) + write_config_file( + pdir, + build_config( + project_name, agent, knowledge=knowledge, skills=skills, episodic=episodic + ), ) register_project(project_name, pdir) - return pdir, mlflow_port + return pdir -def persist_agent_choice(project_name: str, agent: str) -> None: - """Add or update the ``agent:`` field in the project's YAML. +def remove_collection(pdir: Path, collection: str) -> bool: + """Delete a single KB collection directory from a project's ``kb_index``. - Called by ``dsagt start`` on the first run that resolves an agent - from ``--agent`` when the YAML didn't have one — so the next start - doesn't need the flag. Subsequent ``--agent`` overrides at start - are per-run only and don't touch the YAML. + Used by re-run ``dsagt init`` when the user opts to remove a collection + that was dropped from the asset set. Returns True if a directory was + removed. Caller must guard agent-populated collections (``tool_use``, + ``session_memory``) — this helper deletes whatever name it's given. """ - if agent not in VALID_AGENTS: - raise ValueError(f"agent must be one of {VALID_AGENTS}, got '{agent}'") - pdir = project_dir(project_name) - yaml_path = pdir / "dsagt_config.yaml" - raw = yaml.safe_load(yaml_path.read_text()) or {} - raw["agent"] = agent - # Preserve the comment header from default_config_content so - # readers still see the provider hint. Cheap to re-emit. - header = ( - "# llm.provider: LiteLLM provider prefix (selects request format + auth).\n" - "# Common: openai, anthropic, bedrock, vertex_ai, azure, gemini,\n" - "# ollama, mistral, groq, deepseek.\n" - "# Full list: https://docs.litellm.ai/docs/providers\n" - ) - yaml_path.write_text( - header + yaml.dump(raw, default_flow_style=False, sort_keys=False) - ) + target = Path(pdir) / "kb_index" / collection + if target.exists(): + shutil.rmtree(target) + return True + return False def move_project(project_name: str, new_location: Path) -> Path: @@ -462,17 +660,12 @@ def move_project(project_name: str, new_location: Path) -> Path: def remove_project(project_name: str, keep_files: bool = False) -> Path: """Unregister a project. By default also deletes the project directory. - Raises RuntimeError if the project's .runtime file is present (services - still running) — stop the session first, or delete .runtime if stale. + Serverless: there are no background services to reap — ``dsagt start`` + runs the agent in the foreground and returns when it exits — so there + is nothing to stop before removing. """ pdir = project_dir(project_name) - if (pdir / ".runtime").exists(): - raise RuntimeError( - f"Project '{project_name}' has a .runtime file — services may still be " - f"running. Stop the session first, or remove {pdir / '.runtime'} if stale." - ) - if not keep_files and pdir.exists(): shutil.rmtree(pdir) @@ -501,480 +694,66 @@ def _embedding_provider(config: dict) -> str: # --------------------------------------------------------------------------- -# Service supervision -# -# Each ``dsagt start`` writes ``/.runtime`` containing the random -# port MLflow bound + its pid. The next start (or ``dsagt stop``) reads -# that file and reaps anything still alive whose command line still names -# ``mlflow`` — see ``reap_runtime``. Random ports + a project-local -# state file means we never have to ask "is this listener on port 5000 -# mine?" — the file IS the answer. +# Memory extraction orchestration # --------------------------------------------------------------------------- -#: Seconds to wait for SIGTERM-ed processes to exit before SIGKILL. Long -#: enough for uvicorn + mlflow graceful shutdown (a few seconds), short -#: enough that an unresponsive process doesn't drag teardown out forever. -_STOP_GRACE_SECONDS = 5 +def catch_up_extraction(pdir: Path, config: dict) -> dict: + """Background post-session catch-up — run by the MCP server at startup. -def mlflow_command(pdir: Path, mlflow_config: dict, port: int) -> list[str]: - """Build the argv for launching MLflow against a project's store. + The MCP server owns the session lifecycle now: each launch, it spawns + this against a snapshot taken at startup, so it processes the *previous* + session's trailing trace records, never the live one. This removes the + need for a reliable session-*end* trigger (``dsagt start`` no longer runs + any extraction) and gives bare-launched agents full parity. - ``--workers 1``: dsagt is a single-user dev tool — the agent makes - serial LLM calls and MCP-server spans are low-volume. Default - workers=4 each spin up a fresh Python process re-importing MLflow's - full surface (fastapi, sqlalchemy, alembic), so dropping to 1 - shaves ~0.5s off startup with zero observable cost on this load. - """ - mlflow_dir = pdir / "mlflow" - mlflow_dir.mkdir(exist_ok=True) - backend_uri = ( - f"sqlite:///{mlflow_dir}/mlflow.db" - if mlflow_config.get("backend") == "sqlite" - else str(mlflow_dir) - ) - return [ - sys.executable, - "-m", - "mlflow", - "server", - "--backend-store-uri", - backend_uri, - "--default-artifact-root", - str(mlflow_dir / "artifacts"), - "--host", - "0.0.0.0", - "--port", - str(port), - "--workers", - "1", - ] - - -def pick_free_port() -> int: - """Bind ``("", 0)`` so the kernel assigns a free port, then release. + Two phases, both best-effort: - There's a microsecond race between this returning and the subprocess - binding the same port — acceptable on a single-user dev machine. If - the subprocess fails to bind, the mlflow.log tail surfaces the error - via ``_wait_for_mlflow``. + 1. **Tool-execution indexing** (always): embed the previous session's + ``/trace_archive/`` records into the ``tool_use`` collection via + the shared :class:`~dsagt.provenance.ToolUseIndexer` — idempotent against + the same ``.dsagt/tool_use_acks.json`` the live heartbeat uses, so the + startup catch-up and the heartbeat never double-index. No LLM, no + credentials (local-backend default). + 2. **Episodic LLM extraction** (gated on ``DSAGT_MEMORY_*``): forward- + looking plumbing. ``memory.extract_session`` is a no-op stub, so this + reports as unavailable even when the env is set. """ - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("", 0)) - return s.getsockname()[1] + pdir = Path(pdir) + config = {**config, "project_dir": str(pdir)} + kb = kb_from_config(config) - -def _process_command(pid: int) -> str: - """Return the cmdline for *pid*, or ``""`` if dead/unreadable.""" - try: - result = subprocess.run( - ["ps", "-p", str(pid), "-o", "command="], - capture_output=True, - text=True, - check=False, - timeout=2.0, - ) - except (FileNotFoundError, subprocess.TimeoutExpired): - return "" - return result.stdout.strip() - - -def reap_runtime(runtime_file: Path) -> list[str]: - """SIGTERM, grace-wait, SIGKILL any live PIDs in *runtime_file*. - - PID-recycling guard: between writing the PID and reading it back, the - OS could have reassigned that PID to another process; we only signal - if its cmdline still names what we started (``mlflow`` / ``proxy``). - - Used by ``start_services`` to clear leftovers from a prior crashed - run, and by ``stop_services`` (user-invoked teardown). Idempotent — - safe when the file is missing or PIDs are already dead. - """ - if not runtime_file.exists(): - return [] - state = json.loads(runtime_file.read_text()) - pending: dict[str, tuple[int, int]] = {} # name -> (pid, pgid) - for name, pid in state.get("pids", {}).items(): - if name not in _process_command(pid): - continue - try: - pgid = os.getpgid(pid) - os.killpg(pgid, signal.SIGTERM) - pending[name] = (pid, pgid) - except (ProcessLookupError, PermissionError): - pass - - stopped: list[str] = [] - deadline = time.monotonic() + _STOP_GRACE_SECONDS - while pending and time.monotonic() < deadline: - time.sleep(0.2) - for name in list(pending): - pid, pgid = pending[name] - try: - os.killpg(pgid, 0) # liveness probe - except (ProcessLookupError, PermissionError): - stopped.append(f"Stopped {name} (pid {pid})") - del pending[name] - - for name, (pid, pgid) in pending.items(): - try: - os.killpg(pgid, signal.SIGKILL) - stopped.append( - f"Stopped {name} (pid {pid}, SIGKILL after {_STOP_GRACE_SECONDS}s)" - ) - except ProcessLookupError: - stopped.append(f"Stopped {name} (pid {pid})") - - runtime_file.unlink(missing_ok=True) - return stopped - - -def start_services(config: dict) -> dict[str, int]: - """Start MLflow, optionally start the dsagt-proxy too. - - Picks free ports (or honors pre-set ones), reaps any leftovers from a - prior crashed run, writes ``/.runtime`` (pids + ports + - start time), and waits for each service to accept connections. - - Returns ``{"mlflow": port, "proxy": port}`` when the proxy was - requested (``"proxy"`` key present in config), else just - ``{"mlflow": port}``. - - The proxy is opt-in via ``dsagt start --enable-proxy``. Used for - agents that don't natively emit OTel traces with full LLM-call - payloads (cline, roo, codex partial) — the proxy interposes on their - LLM calls and produces traces on their behalf via the same OTLP path - Claude Code uses natively. See ``commands/proxy_server.py``. - """ - pdir = Path(config["project_dir"]) - runtime_file = pdir / ".runtime" - - reap_runtime(runtime_file) # clear leftovers from any prior crashed run - - # KB bootstrap is intentionally NOT here. The contract: - # * ``dsagt setup-kb`` builds shared ~/dsagt-projects/kb_index/ (one-time - # per machine, the only place embedding work happens for bundled - # content) - # * ``dsagt init`` copies the shared collections into the project - # * ``dsagt start`` does no embedding work, no implicit rebuild, - # no sentinel checks — just spawns services - # If the project KB is empty or stale, the MCP servers surface a - # clear "run dsagt setup-kb" error rather than silently rebuilding. - - mlflow_port = config.get("mlflow", {}).get("port") or pick_free_port() - config.setdefault("mlflow", {})["port"] = mlflow_port - - proxy_requested = "proxy" in config - proxy_port = None - if proxy_requested: - proxy_port = config["proxy"].get("port") or pick_free_port() - config["proxy"]["port"] = proxy_port - - session_id = config.get( - "session_id", - f"{config['project']}-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}", - ) - config["session_id"] = session_id - - mlflow_log = pdir / "mlflow.log" - mlflow_proc = subprocess.Popen( - mlflow_command(pdir, config.get("mlflow", {}), port=mlflow_port), - stdout=open(mlflow_log, "w"), - stderr=subprocess.STDOUT, - start_new_session=True, - ) - logger.info( - "MLflow started (pid %d) → http://localhost:%d", - mlflow_proc.pid, - mlflow_port, - ) - - pids = {"mlflow": mlflow_proc.pid} - ports = {"mlflow": mlflow_port} - - proxy_proc = None - if proxy_requested: - # MLflow has to be up before the proxy starts because the proxy's - # init_tracing() calls mlflow.set_experiment() during startup. - _wait_for_mlflow(mlflow_port, mlflow_proc, mlflow_log, timeout=30.0) - - proxy_proc = _start_proxy(config, pdir, mlflow_port, proxy_port, session_id) - pids["proxy"] = proxy_proc.pid - ports["proxy"] = proxy_port - - runtime_file.write_text( - json.dumps( - { - "pids": pids, - "ports": ports, - "started_at": datetime.now(timezone.utc).isoformat(), - }, - indent=2, - ) - + "\n" - ) - - if not proxy_requested: - _wait_for_mlflow(mlflow_port, mlflow_proc, mlflow_log, timeout=30.0) - if proxy_proc is not None: - _wait_for_proxy(proxy_port, proxy_proc, pdir / "proxy.log", timeout=45.0) - - return ports - - -def _start_proxy( - config: dict, - pdir: Path, - mlflow_port: int, - proxy_port: int, - session_id: str, -) -> subprocess.Popen: - """Spawn the dsagt-proxy subprocess. - - Forwards LLM + embedding requests using the user's configured - upstream credentials (LLM_API_KEY / EMBEDDING_API_KEY from - os.environ) and emits OTLP traces to MLflow via init_tracing. - - Crashes loudly if required config keys are missing — better than - spawning a half-configured proxy that 500s on the first agent - request. - """ - llm = config.get("llm") or {} - emb = config.get("embedding") or {} - for required in ("model", "base_url", "provider"): - if not llm.get(required): - raise RuntimeError( - f"--enable-proxy needs config.llm.{required} (got {llm.get(required)!r})" - ) - - cmd = [ - sys.executable, - "-m", - "dsagt.commands.proxy_server", - "--port", - str(proxy_port), - "--mlflow-url", - f"http://localhost:{mlflow_port}", - "--project", - config["project"], - "--session", - session_id, - "--records-dir", - str(pdir / "trace_archive"), - "--model", - llm["model"], - "--base-url", - llm["base_url"], - "--provider", - llm["provider"], - ] - # Embedding routing through the proxy is only relevant when the - # project's embedding backend is ``api`` — in ``local`` mode the - # knowledge MCP server uses sentence-transformers in-process and - # never makes HTTP embedding calls, so the proxy doesn't need an - # embedding route at all. - if (emb.get("backend") or "local").lower() == "api": - for required in ("model", "base_url", "provider"): - if not emb.get(required): - raise RuntimeError( - f"--enable-proxy with embedding.backend=api needs " - f"config.embedding.{required} (got {emb.get(required)!r})" - ) - cmd.extend( - [ - "--embedding-model", - emb["model"], - "--embedding-base-url", - emb["base_url"], - "--embedding-provider", - emb["provider"], - ] - ) - proxy_log = pdir / "proxy.log" - # The proxy needs the *real* upstream credentials in env (not the - # sentinel agents see). os.environ already has them from the user's - # shell or .env file. We pass DSAGT_PROJECT explicitly so - # _resolve_experiment_id picks the right experiment. - proxy_proc = subprocess.Popen( - cmd, - stdout=open(proxy_log, "w"), - stderr=subprocess.STDOUT, - env={ - **os.environ, - "DSAGT_PROJECT": config["project"], - "DSAGT_PROJECT_DIR": str(pdir), - "DSAGT_SESSION_ID": session_id, - "DSAGT_AGENT": config.get("agent", ""), - "MLFLOW_TRACKING_URI": f"http://localhost:{mlflow_port}", - }, - start_new_session=True, - ) - logger.info( - "Proxy started (pid %d) → http://localhost:%d", - proxy_proc.pid, - proxy_port, - ) - return proxy_proc - - -def _wait_for_proxy( - port: int, - proc: subprocess.Popen, - log_path: Path, - timeout: float = 45.0, -) -> None: - """Poll *port* until the proxy answers, the subprocess dies, or we time out. - - Generous default (45s) because LiteLLM's transitive imports - (transformers/torch dependencies) can take 10-15s on warm cache, - longer cold. Raises with proxy.log tail attached so - ``dsagt start`` surfaces the failure instead of the agent's first - LLM call hitting ECONNREFUSED. - """ - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - if proc.poll() is not None: - tail = log_path.read_text().splitlines()[-20:] if log_path.exists() else [] - raise RuntimeError( - f"Proxy exited with code {proc.returncode} before becoming ready.\n " - + "\n ".join(tail) - ) - try: - with socket.create_connection(("127.0.0.1", port), timeout=0.5): - return - except (ConnectionRefusedError, OSError): - time.sleep(0.25) - raise RuntimeError( - f"Proxy did not accept connections on port {port} within {timeout:.0f}s. " - f"See {log_path} for details." - ) - - -def _wait_for_mlflow( - port: int, - proc: subprocess.Popen, - log_path: Path, - timeout: float = 30.0, -) -> None: - """Poll *port* until MLflow answers, the subprocess dies, or we time out. - - Raises ``RuntimeError`` on failure with the mlflow.log tail attached, - so the failure surfaces at ``dsagt start`` rather than at the agent's - first OTLP export attempt. - """ - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - if proc.poll() is not None: - tail = log_path.read_text().splitlines()[-20:] if log_path.exists() else [] - raise RuntimeError( - f"MLflow exited with code {proc.returncode} before becoming ready.\n " - + "\n ".join(tail) - ) - try: - with socket.create_connection(("127.0.0.1", port), timeout=0.5): - return - except (ConnectionRefusedError, OSError): - time.sleep(0.25) - raise RuntimeError( - f"MLflow did not accept connections on port {port} within {timeout:.0f}s. " - f"See {log_path} for details." - ) - - -def stop_services(project_name: str) -> list[str]: - """User-invoked teardown. Returns ``[]`` when nothing was running.""" - return reap_runtime(project_dir(project_name) / ".runtime") - - -# --------------------------------------------------------------------------- -# Memory extraction orchestration -# --------------------------------------------------------------------------- - - -def run_extraction(project_name: str) -> dict: - """Two-phase post-session work, both best-effort. - - 1. **Tool-execution indexing** (always): embed every JSON record in - ``/trace_archive/`` into the project's ``tool_use`` - collection so the agent can semantic-search prior tool runs in - future sessions. Pure embedding work — no LLM call, no - ``DSAGT_MEMORY_*`` needed. Local-backend embeddings (BYOA - default) need no credentials at all. - 2. **LLM-based memory extraction** (gated on ``DSAGT_MEMORY_*``): - summarises MLflow LLM-call traces into episodic memories. - Currently reads only ``service.name == "dsagt-proxy"`` traces - (see ``memory.DSAGT_EXTRACTION_SOURCE_SERVICE_NAME``); in BYOA - these don't exist yet, so this phase is effectively dormant - until Phase 2 / native-shape parsers land. - """ - config = load_config(project_name) - pdir = Path(config["project_dir"]) - - emb_config = config.get("embedding", {}) - backend = emb_config.get("backend", "local") - # Local backend rejects base_url / api_key (no remote call); api - # backend reads creds from the shell. Local model must be a HF - # identifier (``org/repo``); if it isn't (legacy projects had - # Ollama-style ``nomic-embed-text`` here), fall through to - # LocalEmbeddingClient's default by passing model=None. - if backend == "local": - model = emb_config.get("model") - if model and "/" not in str(model): - model = None - embedder_kwargs = {"model": model} - else: - embedder_kwargs = { - "model": emb_config.get("model"), - "base_url": emb_config.get("base_url"), - "api_key": os.environ.get("EMBEDDING_API_KEY", ""), - } - kb = KnowledgeBase( - index_dir=pdir / "kb_index", - default_embedder=backend, - embedder_kwargs=embedder_kwargs, - ) - - # Phase 1: index trace_archive into tool_use collection. tool_use_indexed = 0 try: - trace_result = index_trace_archive(pdir / "trace_archive", kb) - tool_use_indexed = trace_result.get("indexed", 0) - except Exception as e: + tool_use_indexed = ToolUseIndexer(kb, pdir).tick() + except Exception as e: # noqa: BLE001 — never let a background task crash logger.warning("Tool execution indexing failed: %s", e) - # Phase 2: LLM-based memory extraction. Skip silently if not configured. + # Episodic-memory extraction (stub until Phase 3). Skip silently if not + # configured. api_key = os.environ.get("DSAGT_MEMORY_API_KEY", "") model = os.environ.get("DSAGT_MEMORY_MODEL", "") if not api_key or not model: kb.close() return {"status": "tool_use_only", "tool_use_indexed": tool_use_indexed} - base_url = os.environ.get("DSAGT_MEMORY_BASE_URL", "") - provider = os.environ.get("DSAGT_MEMORY_PROVIDER") or None - session_id = config.get("session_id") or config.get("project", "") - categories = config.get("categories", {}) + from dsagt.observability import resolve_tracking_uri - mlflow_port = config.get("mlflow", {}).get("port") - mlflow_uri = ( - f"http://localhost:{mlflow_port}" - if mlflow_port - else os.environ.get("MLFLOW_TRACKING_URI") - ) try: result = extract_session( - project_name=project_name, + project_name=config.get("project", ""), kb=kb, api_key=api_key, model=model, - base_url=base_url or None, - provider=provider, - session_id=session_id, - categories=categories if categories else None, + base_url=os.environ.get("DSAGT_MEMORY_BASE_URL") or None, + provider=os.environ.get("DSAGT_MEMORY_PROVIDER") or None, + session_id=current_session_tag(pdir, config.get("project", "")), + categories=None, runtime_dir=pdir, outlier_sensitivity=float( - config.get("extraction", {}).get("outlier_sensitivity", 0) + os.environ.get("DSAGT_MEMORY_OUTLIER_SENSITIVITY", "0") or 0 ), - mlflow_uri=mlflow_uri, + mlflow_uri=resolve_tracking_uri(config), ) result["tool_use_indexed"] = tool_use_indexed return result diff --git a/src/dsagt/skills.py b/src/dsagt/skills.py index 27627d5..d3a3804 100644 --- a/src/dsagt/skills.py +++ b/src/dsagt/skills.py @@ -1,38 +1,39 @@ """Skill discovery — catalog data plane, keyword scorer, and the router facade. -One module for all the importable skill-discovery logic that is *not* an entry -point (entry points — the MCP tool handlers — stay in -``commands/registry_server.py`` and ``commands/knowledge_server.py``). Three -cohesive concerns, in dependency order: - -1. **Keyword scorer** (:func:`score_skill` / :func:`rank_skills`) — a faithful - reimplementation of the Genesis Skills ``skill-search`` engine, the - zero-dependency fallback ranker used when no embedder / KB is configured. -2. **Catalog data plane** (:class:`SkillsCatalog` + its module functions) — - fetch Agent-Skills repos, index per-source into ``skills_catalog__`` - collections, search, and install into a project. -3. **Router facade** (:class:`SkillRouter`) — the thin render layer the MCP - ``search_skills`` tool and the ``dsagt skills`` CLI share. - -Two tiers (see the skill-management plan): - -* **Catalog** — every skill in a configured source repo, indexed into a - per-source ``skills_catalog__`` KB collection. Searchable via - ``search_skills``, but NOT copied locally and NOT loaded into the agent's - context. This is the one job native skill discovery can't do (you can't hold - thousands of skill descriptions in context). -* **Installed** — a chosen skill copied into ``/skills//``. The - agent setup mirrors it into the agent's native skills dir for native - discovery (see ``agents.base.setup_skills``). - -Re-sync is idempotent by dropping the per-source collection directory and -rebuilding it. ``clone_github`` is imported lazily inside :func:`sync_source` -to avoid an import cycle with ``setup_core_kb`` (which calls back into -:func:`sync_source`). +DSAGT fetches external Agent-Skills repos, indexes each per-source into a +``skills_catalog__`` KB collection, and searches/installs them into a +project. This is the one job native skill discovery can't do: a *catalog* skill +stays searchable without being copied locally or held in the agent's context +(you can't hold thousands of skill descriptions in context), while an +*installed* skill is copied into ``/skills//`` and mirrored into +the agent's native skills dir (``agents.base.setup_skills``). It backs the MCP +``search_skills`` tool and the ``dsagt skills`` CLI through the one +:class:`SkillRouter` facade, so search/install policy can't diverge between them. +Design-wise it stays cheap and degradable: :class:`SkillsCatalog` composes over +the host server's :class:`~dsagt.knowledge.KnowledgeBase` (shared embedder, no +second model load), falls back to a Genesis-derived keyword scorer +(:func:`rank_skills`) when no embedder/KB is configured, and indexes per-source +so re-sync is an idempotent drop-and-rebuild of just that source's collection. + +Class map — every edge is `` Class`` (``◇`` holds · ``◆`` owns):: + + SkillRouter render/MCP facade: the search_skills string, + │ the empty-result message, exact-name lookup + ├─◇ SkillsCatalog the catalog data plane (constructed here, or + │ │ shared in via catalog=) + │ └─◇ KnowledgeBase shared vector store + embedder; None selects + │ the keyword fallback over the clone cache + └─◇ SkillRegistry installed-skill registry, exact-name lookup only + + free fns: + keyword scorer score_skill · rank_skills (Genesis parity) + source resolve resolve_source · _repo_slug · persist_source_to_config + sync / index sync_source · _discover_skill_dirs · index_catalog + install find_catalog_skill · install_into_project · _capture_attribution + render _where_label Genesis Skills: Apache-2.0, gitlab.osti.gov/genesis/genesis-skills -(``skill_search/catalog.py``). See ``design-notes/genesis-skills-comparison.md`` -and ``design-notes/skills-catalog-server-merge.md``. +(``skill_search/catalog.py``). """ from __future__ import annotations @@ -43,6 +44,8 @@ import shutil from pathlib import Path +import yaml + from dsagt.registry import ( CATALOG_COLLECTION_PREFIX, _parse_frontmatter, @@ -118,9 +121,6 @@ def score_skill(query: str, name: str, description: str) -> float: """Token-overlap score of one skill against *query* (0.0 = no match).""" qtokens = _tokens(query) normalized_query = (query or "").casefold().strip() - if not qtokens and not normalized_query: - return 0.0 - score = 2 * len(qtokens & _tokens(name)) + len(qtokens & _tokens(description)) if normalized_query: @@ -157,13 +157,13 @@ def rank_skills( # =========================================================================== #: Default source enabled out of the box (matches dsagt_config.yaml default). -DEFAULT_SOURCE = "scientific" +DEFAULT_SOURCE = "k-dense-ai" #: Curated, named skill sources. ``subdir`` scopes the recursive SKILL.md #: walk when set (cheaper clone); when omitted the whole repo is cloned and #: walked, which is robust to category-nested layouts. KNOWN_SOURCES: dict[str, dict] = { - "scientific": { + "k-dense-ai": { "url": "https://github.com/K-Dense-AI/scientific-agent-skills", "branch": "main", "subdir": "skills", @@ -243,9 +243,7 @@ def persist_source_to_config(project_dir: str | Path, spec: dict) -> bool: ``dsagt skills add`` CLI so a CLI-added source is re-synced by a later config-driven ``dsagt skills sync``. """ - import yaml - - cfg_path = Path(project_dir) / "dsagt_config.yaml" + cfg_path = Path(project_dir) / ".dsagt" / "config.yaml" if not cfg_path.exists(): return False cfg = yaml.safe_load(cfg_path.read_text()) or {} @@ -607,26 +605,25 @@ def search(self, query=None, *, top_k: int = 8, tag=None) -> list[dict]: return self._select_keyword(query, top_k, tag) def _select_kb(self, query, top_k: int, tag) -> list[dict]: - """Semantic backend: ChromaDB search across every synced catalog - collection, merged + sorted by score into normalized hit dicts. + """Semantic backend: rank-fused search across every synced catalog + collection, normalized into hit dicts. - Each ``skills_catalog__*`` collection is queried independently (a - missing/corrupt one is skipped, not fatal); when a ``tag`` filter is - set we over-fetch (``top_k * 3``) then post-filter so the tag doesn't - starve the result set. + Fan-out + RRF live in ``KnowledgeBase.search`` (the shared substrate); + catalog collections are homogeneous (one embedder) so the fusion is a + clean rank merge. When a ``tag`` filter is set we over-fetch + (``top_k * 3``) then post-filter so the tag doesn't starve the results. """ collections = self.synced_collections() + if not collections: + return [] fetch_k = top_k * 3 if tag else top_k - hits: list[dict] = [] - for coll in collections: - try: - hits.extend( - self._kb.search( - query=query or "skill", collection=coll, top_k=fetch_k - ) - ) - except (FileNotFoundError, KeyError, ValueError): - continue + # collections all come from synced_collections() (they exist), and + # KnowledgeBase.search already skips a missing collection with a warning + # and raises only when every target fails — so a raise here is a real + # failure worth surfacing, not a can't-happen state to swallow. + hits = self._kb.search( + query=query or "skill", collections=collections, top_k=fetch_k + ) out = [] for r in hits: chunk = r.get("chunk", {}) @@ -740,7 +737,7 @@ def _indexed_count(self, collection: str) -> int: # # Skill *materialization* (mirroring installed skills into each agent's native # skills directory) lives in the agent layer (``AgentSetup.setup_skills``), not -# here: every supported agent (claude/codex/goose/cline/roo) natively +# here: every supported agent (claude/codex/goose/cline) natively # auto-discovers ``SKILL.md`` folders, so there is no agent-facing disclosure # tier for the router to own. ``search_skills`` exists for the *catalog* tier # (skills not yet installed, which native discovery can't see) plus the @@ -814,8 +811,6 @@ def _empty_message(self) -> str: def search(self, query=None, *, top_k: int = 8, tag=None, skill_name=None) -> str: """Stage B. Select + render. Stateless — no session/exposure tracking.""" if skill_name: - import yaml - if self._reg is None: return f"No skill named '{skill_name}'." spec = self._reg.get_skill(skill_name) diff --git a/src/dsagt/tools/scan_directory.py b/src/dsagt/tools/scan_directory.py index a54742b..c4c4ebd 100644 --- a/src/dsagt/tools/scan_directory.py +++ b/src/dsagt/tools/scan_directory.py @@ -57,7 +57,9 @@ def du_total_bytes(directory: Path) -> int: return kb * 1024 -def iter_files_sizes_relative(directory: Path, max_depth: int) -> tuple[list[tuple[int, str]], list[dict]]: +def iter_files_sizes_relative( + directory: Path, max_depth: int +) -> tuple[list[tuple[int, str]], list[dict]]: """ Enumerate files under `directory` up to `max_depth`, INCLUDING hidden. Uses: find + stat (platform-specific flags) via -exec ... {} + @@ -102,10 +104,17 @@ def iter_files_sizes_relative(directory: Path, max_depth: int) -> tuple[list[tup continue items.append((size, rel)) except Exception: - errors.append({"path": str(directory), "error": f"Unparseable stat line: {line[:200]}"}) + errors.append( + { + "path": str(directory), + "error": f"Unparseable stat line: {line[:200]}", + } + ) if p.returncode != 0: - errors.append({"path": str(directory), "error": f"find/stat exited {p.returncode}"}) + errors.append( + {"path": str(directory), "error": f"find/stat exited {p.returncode}"} + ) return items, errors @@ -158,24 +167,32 @@ def scan(directory: Path, max_depth: int, top_n: int) -> dict: "total_size": v["total_size"], "total_size_human": format_size(v["total_size"]), } - for k, v in sorted(ext_summary.items(), key=lambda x: x[1]["total_size"], reverse=True) + for k, v in sorted( + ext_summary.items(), key=lambda x: x[1]["total_size"], reverse=True + ) ] - largest_files = [e for _, _, e in sorted(largest_heap, key=lambda t: (t[0], t[1]), reverse=True)] + largest_files = [ + e for _, _, e in sorted(largest_heap, key=lambda t: (t[0], t[1]), reverse=True) + ] - directory_tree = [{ - "directory": ".", - "size": total_size, - "file_count": total_files, - "size_human": format_size(total_size), - }] + directory_tree = [ + { + "directory": ".", + "size": total_size, + "file_count": total_files, + "size_human": format_size(total_size), + } + ] for d in sorted(dir_summary): - directory_tree.append({ - "directory": d, - "size": dir_summary[d]["size"], - "file_count": dir_summary[d]["file_count"], - "size_human": format_size(dir_summary[d]["size"]), - }) + directory_tree.append( + { + "directory": d, + "size": dir_summary[d]["size"], + "file_count": dir_summary[d]["file_count"], + "size_human": format_size(dir_summary[d]["size"]), + } + ) report = { "directory": str(directory.resolve()), @@ -195,7 +212,9 @@ def scan(directory: Path, max_depth: int, top_n: int) -> dict: def main(): - parser = argparse.ArgumentParser(description="Scan directory structure and file sizes") + parser = argparse.ArgumentParser( + description="Scan directory structure and file sizes" + ) parser.add_argument("directory", help="Path to directory to scan") # accept both hyphen and underscore variants (registry runners vary) parser.add_argument("--max-depth", "--max_depth", type=int, default=5) @@ -204,7 +223,9 @@ def main(): directory = Path(args.directory) if not directory.exists(): - print(json.dumps({"error": f"Directory not found: {directory}"}), file=sys.stderr) + print( + json.dumps({"error": f"Directory not found: {directory}"}), file=sys.stderr + ) sys.exit(1) if not directory.is_dir(): print(json.dumps({"error": f"Not a directory: {directory}"}), file=sys.stderr) diff --git a/src/dsagt/traces.py b/src/dsagt/traces.py new file mode 100644 index 0000000..136b987 --- /dev/null +++ b/src/dsagt/traces.py @@ -0,0 +1,1281 @@ +""" +Trace pipeline — read each agent's on-disk session, normalize it to one common +``Trace``, and hand that to whatever consumes traces (the MLflow logger in +:mod:`dsagt.observability`, the episodic-memory distiller in :mod:`dsagt.memory`). + +While a session runs, the MCP server wakes on a timer and, for the running agent: +a **Reader** finds and reads the platform's session files on disk into raw +records; the matching **Translator** maps those records into one **Trace**; and +the **TraceCollector** hands the result to its consumers, skipping the turns it +already handled. + + A session ``Trace`` carries one AGENT subtree per turn + (an AGENT root with ``llm`` / ``tool_`` +children), matching the per-prompt granularity MLflow's own claude autolog +produces. Fidelity is capped by what the transcript persisted: every timestamp +and token count is ``None``-tolerant. + +The Claude grammar below is ported from MLflow's +``claude_code/tracing.py`` — © Databricks, Inc., Apache-2.0 — specifically its +turn-windowing, skill/command skips, and next-timestamp span durations. See +NOTICE. + +Class map — ``▷`` inherits · ``◆`` owns · ``◇`` holds (``*`` = many):: + + Trace one session: id fields + spans (list of dicts) + + compose / query / to_exchanges methods + + Reader «abstract» locate + read a platform's session → raw records + ├─▷ JsonlReader «abstract» shared whole-file line framing; active_file() hook + │ ├─▷ ClaudeReader ~/.claude/projects//*.jsonl (newest) + │ └─▷ CodexReader ~/.codex/sessions/**/rollout-*.jsonl (by cwd) + ├─▷ GooseReader goose sessions.db (sqlite, read-only) + ├─▷ OpenCodeReader opencode.db (sqlite, read-only) + └─▷ ClineReader ~/.cline/.../.messages.json (whole file) + + Translator «abstract» raw records → Trace (pure); shared turn template + ├─▷ ClaudeTranslator overrides translate() — bespoke grammar + ├─▷ CodexTranslator fills parse hooks (+ normalize / prompt-index) + ├─▷ GooseTranslator fills parse hooks + ├─▷ OpenCodeTranslator fills parse hooks + └─▷ ClineTranslator fills parse hooks + + TraceCollector the driver: read → translate → hand to consumers + (MLflow logger, memory distiller); a per-consumer + ack set makes repeated passes idempotent +""" + +from __future__ import annotations + +import fcntl +import json +import logging +import os +import re +import sqlite3 +import threading +from abc import ABC, abstractmethod +from contextlib import contextmanager +from datetime import datetime +from pathlib import Path + +logger = logging.getLogger(__name__) + +_DEFAULT_SPAN_SECONDS = ( + 1.0 # fallback turn-span duration when no next timestamp bounds it +) +_ROLE_ASSISTANT = "assistant" +_ROLE_USER = "user" +_TYPE_QUEUE_OP = "queue-operation" + + +def _parse_ts(ts: object) -> float | None: + """ISO-8601 string (or epoch number) → epoch seconds; ``None`` on failure.""" + if not ts: + return None + if isinstance(ts, (int, float)): + return float(ts) + if isinstance(ts, str): + try: + return datetime.fromisoformat(ts.replace("Z", "+00:00")).timestamp() + except ValueError: + return None + return None + + +# --------------------------------------------------------------------------- +# The block / message / usage shapes (the one place they're constructed) +# --------------------------------------------------------------------------- + + +def _text_block(text: str | None) -> dict: + return {"type": "text", "text": text or ""} + + +def _tool_use_block(name, tool_input, tool_call_id=None) -> dict: + return { + "type": "tool_use", + "id": tool_call_id, + "name": name, + "input": tool_input or {}, + } + + +def _tool_result_block(output, tool_call_id=None) -> dict: + return {"type": "tool_result", "tool_use_id": tool_call_id, "content": output} + + +def _message(role: str, blocks: list[dict]) -> dict: + return {"role": role, "content": blocks} + + +def _usage(raw: dict | None) -> dict | None: + """Token counts from a transcript ``usage`` dict; ``None`` when absent.""" + if not raw: + return None + return { + "input_tokens": raw.get("input_tokens"), + "output_tokens": raw.get("output_tokens"), + "cache_read_input_tokens": raw.get("cache_read_input_tokens"), + "cache_write_input_tokens": raw.get("cache_creation_input_tokens"), + } + + +# =========================================================================== +# Trace — the canonical form (nested data + composition / query methods) +# =========================================================================== + + +class Trace: + """One finished agent session: id fields plus a list of span dicts. + + The span/message/block shapes are documented in the module docstring and + built *only* by the ``add_*`` methods here, so the schema has one home. + Consumers read ``spans`` (and ``to_exchanges()``) directly — the data is + already in the dict shape both the MLflow logger and memory want. + """ + + def __init__(self, trace_id: str, session_id: str, agent: str, project: str): + self.trace_id = trace_id + self.session_id = session_id + self.agent = agent + self.project = project + self.spans: list[dict] = [] + + @property + def started_at(self) -> float | None: + return self.spans[0]["start_time"] if self.spans else None + + @property + def ended_at(self) -> float | None: + return self.spans[-1]["end_time"] if self.spans else None + + # -- composition (the only place a span dict is constructed) ------------- + + def add_agent_root(self, span_id, name, *, start_time, prompt) -> dict: + span = { + "span_id": span_id, + "name": name, + "kind": "AGENT", + "parent_id": None, + "start_time": start_time, + "end_time": None, + "status": "ok", + "request": [], + "response": [], + "model": None, + "usage": None, + "attributes": {"prompt": prompt}, + } + self.spans.append(span) + return span + + def add_llm_span( + self, + span_id, + *, + parent_id, + start_time, + end_time, + request, + response, + model=None, + usage=None, + ) -> dict: + span = { + "span_id": span_id, + "name": "llm", + "kind": "LLM", + "parent_id": parent_id, + "start_time": start_time, + "end_time": end_time, + "status": "ok", + "request": request, + "response": response, + "model": model, + "usage": usage, + "attributes": {}, + } + self.spans.append(span) + return span + + def add_tool_span( + self, + span_id, + *, + parent_id, + start_time, + end_time, + name, + tool_input, + result, + tool_id="", + ) -> dict: + span = { + "span_id": span_id, + "name": f"tool_{name}", + "kind": "TOOL", + "parent_id": parent_id, + "start_time": start_time, + "end_time": end_time, + "status": "ok", + "request": [], + "response": [], + "model": None, + "usage": None, + "attributes": { + "tool_name": name, + "tool_id": tool_id, + "input": tool_input, + "result": result, + }, + } + self.spans.append(span) + return span + + def add_turn(self, *, root_id, root_name, prompt, root_ts, events, last_ts) -> None: + """Append one AGENT subtree from a turn's ordered ``events``. + + Each event is a tuple — ``("llm", ts, text, model, usage)`` or + ``("tool", ts, name, input, result)`` — in transcript order. This is the + shared builder the four template translators use: it derives each span's + duration from the next event's timestamp (1s fallback for the last), and + threads the request "window" (the prompt, then each tool call+result) + into the following ``llm`` span's ``request`` — which is what memory's + ``to_exchanges`` reads. + """ + root = self.add_agent_root( + root_id, root_name, start_time=root_ts, prompt=prompt + ) + pending = [_message(_ROLE_USER, [_text_block(prompt)])] + ts_list = [e[1] for e in events] + final_response: str | None = None + for i, ev in enumerate(events): + ts = ev[1] + nxt = next((t for t in ts_list[i + 1 :] if t is not None), last_ts) + dur = ( + (nxt - ts) + if (ts is not None and nxt is not None and nxt > ts) + else _DEFAULT_SPAN_SECONDS + ) + end = (ts + dur) if ts is not None else None + if ev[0] == "llm": + _, _, text, model, usage = ev + final_response = text + self.add_llm_span( + f"{root_id}-{i}", + parent_id=root_id, + start_time=ts, + end_time=end, + request=list(pending), + response=[_text_block(text)], + model=model, + usage=usage, + ) + pending = [] + else: # "tool" + _, _, name, tin, result = ev + self.add_tool_span( + f"{root_id}-{i}", + parent_id=root_id, + start_time=ts, + end_time=end, + name=name, + tool_input=tin, + result=result, + ) + tool_input = tin if isinstance(tin, dict) else {"raw": tin} + pending.append( + _message(_ROLE_ASSISTANT, [_tool_use_block(name, tool_input)]) + ) + pending.append(_message(_ROLE_USER, [_tool_result_block(result)])) + root["end_time"] = last_ts if last_ts is not None else root_ts + if final_response is not None: + root["attributes"]["response"] = final_response + + # -- query / projection ------------------------------------------------- + + def roots(self) -> list[dict]: + return [s for s in self.spans if s["parent_id"] is None] + + def children(self, root_id) -> list[dict]: + return [s for s in self.spans if s["parent_id"] == root_id] + + def subset(self, root_ids: set[str]) -> "Trace": + """A copy carrying only the given AGENT roots and their direct children.""" + keep = [ + s + for s in self.spans + if (s["parent_id"] is None and s["span_id"] in root_ids) + or s["parent_id"] in root_ids + ] + out = Trace(self.trace_id, self.session_id, self.agent, self.project) + out.spans = keep + return out + + def to_exchanges(self) -> list[dict]: + """Project the ``llm`` spans onto memory's conversational shape. + + One ``llm`` span → one ``{timestamp, new_messages, response}`` exchange; + ``request`` is already the windowed message list, ``response`` the output + blocks — so this is a straight projection, no diffing. + """ + return [ + { + "timestamp": s["start_time"], + "new_messages": s["request"], + "response": s["response"], + } + for s in self.spans + if s["kind"] == "LLM" + ] + + +# =========================================================================== +# Readers — locate + read a platform's session record into raw records +# =========================================================================== + + +class Reader(ABC): + """Find this project's active session for an agent and read its records.""" + + agent: str + + @abstractmethod + def read(self) -> list[dict]: + """The whole active session's raw records (re-read each pass; the + collector's ack set dedupes, so reads need not be incremental).""" + + +class JsonlReader(Reader): + """Read the whole active ``*.jsonl`` file, framing only complete lines. + + A trailing half-written line is dropped (picked up next pass). Subclasses + supply :meth:`active_file`; the framing is identical for claude and codex. + """ + + @abstractmethod + def active_file(self) -> Path | None: ... + + def read(self) -> list[dict]: + f = self.active_file() + if f is None: + return [] + with open(f, "rb") as fh: + chunk = fh.read() + last_nl = chunk.rfind(b"\n") + if last_nl == -1: + return [] + return [ + json.loads(line) + for line in chunk[: last_nl + 1].splitlines() + if line.strip() + ] + + +def _transcript_dir(project_dir: str | Path, projects_root: Path | None = None) -> Path: + """``~/.claude/projects/`` for a project directory. + + Claude derives the dir name by replacing every non-alphanumeric character of + the launch cwd with ``-``. The MCP server launches as ``cd && + claude``, so cwd == project_dir. + """ + root = projects_root or (Path.home() / ".claude" / "projects") + mangled = re.sub(r"[^a-zA-Z0-9]", "-", os.path.abspath(project_dir)) + return root / mangled + + +class ClaudeReader(JsonlReader): + """The most-recently-modified transcript in the project's ``~/.claude`` dir.""" + + agent = "claude" + + def __init__(self, project_dir, *, projects_root: Path | None = None): + self._dir = _transcript_dir(project_dir, projects_root) + + def active_file(self) -> Path | None: + if not self._dir.is_dir(): + return None + files = list(self._dir.glob("*.jsonl")) + return max(files, key=lambda p: p.stat().st_mtime) if files else None + + +_CODEX_SESSIONS_ROOT = Path.home() / ".codex" / "sessions" + + +class CodexReader(JsonlReader): + """The newest ``rollout-*.jsonl`` whose ``session_meta.cwd`` is this project. + + Codex rollouts live globally under ``~/.codex/sessions/YYYY/MM/DD/``; each + opens with a ``session_meta`` record carrying the launch ``cwd``. + """ + + agent = "codex" + + def __init__(self, project_dir, *, sessions_root: Path | None = None): + self._project_dir = os.path.abspath(project_dir) + self._root = Path(sessions_root) if sessions_root else _CODEX_SESSIONS_ROOT + + def _rollout_cwd(self, path: Path) -> str | None: + with open(path, encoding="utf-8") as fh: + first = fh.readline() + if not first.strip(): + return None + rec = json.loads(first) + if rec.get("type") != "session_meta": + return None + return (rec.get("payload") or {}).get("cwd") + + def active_file(self) -> Path | None: + if not self._root.is_dir(): + return None + files = sorted( + self._root.glob("**/rollout-*.jsonl"), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + for f in files: + if self._rollout_cwd(f) == self._project_dir: + return f + return None + + +_GOOSE_DB = Path.home() / ".local" / "share" / "goose" / "sessions" / "sessions.db" + + +class GooseReader(Reader): + """Read the project's active Goose session from its SQLite store (read-only).""" + + agent = "goose" + + def __init__(self, project_dir, *, db_path: Path | None = None): + self._project_dir = os.path.abspath(project_dir) + self._db = Path(db_path) if db_path else _GOOSE_DB + + def read(self) -> list[dict]: + if not self._db.exists(): + return [] + con = sqlite3.connect(f"file:{self._db}?mode=ro", uri=True) + try: + row = con.execute( + "SELECT id FROM sessions WHERE working_dir = ? " + "ORDER BY updated_at DESC LIMIT 1", + (self._project_dir,), + ).fetchone() + if row is None: + return [] + return [ + {"role": role, "content": _loads_list(cj), "ts": ts} + for role, cj, ts in con.execute( + "SELECT role, content_json, created_timestamp FROM messages " + "WHERE session_id = ? ORDER BY id", + (row[0],), + ) + ] + finally: + con.close() + + +_OPENCODE_DB = Path.home() / ".local" / "share" / "opencode" / "opencode.db" + + +class OpenCodeReader(Reader): + """Read + flatten the project's active opencode session (sqlite, read-only). + + opencode splits a session into ``message`` rows (role/model) and ``part`` + rows (the text/tool payloads); this joins them into one time-ordered list of + parts, each tagged with its message's role + model and a timestamp in + seconds (opencode stores milliseconds). + """ + + agent = "opencode" + + def __init__(self, project_dir, *, db_path: Path | None = None): + self._project_dir = os.path.abspath(project_dir) + self._db = Path(db_path) if db_path else _OPENCODE_DB + + def read(self) -> list[dict]: + if not self._db.exists(): + return [] + con = sqlite3.connect(f"file:{self._db}?mode=ro", uri=True) + try: + row = con.execute( + "SELECT id FROM session WHERE directory = ? " + "ORDER BY time_updated DESC LIMIT 1", + (self._project_dir,), + ).fetchone() + if row is None: + return [] + sid = row[0] + messages = { + mid: _loads_dict(d) + for mid, d in con.execute( + "SELECT id, data FROM message WHERE session_id = ?", (sid,) + ) + } + parts = [] + for mid, t_created, data in con.execute( + "SELECT message_id, time_created, data FROM part " + "WHERE session_id = ? ORDER BY time_created", + (sid,), + ): + m = messages.get(mid, {}) + parts.append( + { + "role": m.get("role"), + "model": (m.get("model") or {}).get("modelID"), + "ts": t_created / 1000.0, + "data": _loads_dict(data), + } + ) + return parts + finally: + con.close() + + +_CLINE_SESSIONS_ROOT = Path.home() / ".cline" / "data" / "sessions" + + +class ClineReader(Reader): + """Locate the project's active Cline CLI session by its metadata ``cwd``. + + Each session is ``~/.cline/data/sessions//`` with ``.json`` (metadata + incl. ``cwd`` + ``model``) and ``.messages.json`` (the whole message list). + """ + + agent = "cline" + + def __init__(self, project_dir, *, sessions_root: Path | None = None): + self._project_dir = os.path.abspath(project_dir) + self._root = Path(sessions_root) if sessions_root else _CLINE_SESSIONS_ROOT + + def _active_dir(self) -> Path | None: + if not self._root.is_dir(): + return None + for d in sorted(self._root.iterdir(), key=lambda p: p.name, reverse=True): + meta = d / f"{d.name}.json" + if not meta.exists(): + continue + try: + cwd = json.loads(meta.read_text()).get("cwd", "") + except (json.JSONDecodeError, OSError): + continue + if os.path.abspath(cwd) == self._project_dir: + return d + return None + + def read(self) -> list[dict]: + d = self._active_dir() + if d is None: + return [] + msgs_path = d / f"{d.name}.messages.json" + if not msgs_path.exists(): + return [] + try: + data = json.loads(msgs_path.read_text()) + except (json.JSONDecodeError, OSError): + return [] + model = self._session_model(d) + messages = data.get("messages", []) if isinstance(data, dict) else data + for m in messages: + m["model"] = model + return messages + + def _session_model(self, session_dir: Path) -> str | None: + try: + meta = json.loads((session_dir / f"{session_dir.name}.json").read_text()) + return meta.get("model") + except (json.JSONDecodeError, OSError): + return None + + +def _loads_list(raw: str) -> list: + try: + out = json.loads(raw) + return out if isinstance(out, list) else [] + except (json.JSONDecodeError, TypeError): + return [] + + +def _loads_dict(raw: str) -> dict: + try: + out = json.loads(raw) + return out if isinstance(out, dict) else {} + except (json.JSONDecodeError, TypeError): + return {} + + +# =========================================================================== +# Translators — raw records → Trace (pure, no I/O) +# =========================================================================== + + +class Translator(ABC): + """Map one platform's records to a :class:`Trace`. + + The default ``translate`` is the shared template every platform but Claude + uses: build a tool-result map, find the turn-start (prompt) indices, and for + each turn lower its records into ordered ``("llm"|"tool", …)`` events that + ``Trace.add_turn`` turns into a span subtree. A subclass supplies the small + parse hooks (``_is_prompt`` / ``_prompt_text`` / ``_ts`` / ``_events`` and, + where needed, ``_tool_results`` / ``_normalize`` / ``_prompt_indices``). + Claude overrides ``translate`` outright — its grammar exceeds this shape. + """ + + agent: str + root_name: str + + def translate(self, records, *, trace_id, session_id, project) -> Trace | None: + records = self._normalize(records) + results = self._tool_results(records) + prompts = self._prompt_indices(records) + if not prompts: + return None + trace = Trace(trace_id, session_id, self.agent, project) + bounds = prompts + [len(records)] + for k, start in enumerate(prompts): + self._build_turn(trace, records, start, bounds[k + 1], results) + return trace if trace.spans else None + + def _build_turn(self, trace, records, start, end, results) -> None: + root_ts = self._ts(records[start]) + last_ts = root_ts + events = [] + for i in range(start + 1, end): + rec = records[i] + ts = self._ts(rec) + if ts is not None: + last_ts = ts + events.extend(self._events(rec, ts, results)) + trace.add_turn( + root_id=f"turn-{start}", + root_name=self.root_name, + prompt=self._prompt_text(records[start]), + root_ts=root_ts, + events=events, + last_ts=last_ts, + ) + + # -- hooks (defaults; concrete translators fill what they need) ---------- + + def _normalize(self, records): + return records + + def _prompt_indices(self, records) -> list[int]: + return [i for i, r in enumerate(records) if self._is_prompt(r)] + + def _tool_results(self, records) -> dict: + return {} + + def _is_prompt(self, rec) -> bool: + raise NotImplementedError + + def _prompt_text(self, rec) -> str: + raise NotImplementedError + + def _ts(self, rec) -> float | None: + raise NotImplementedError + + def _events(self, rec, ts, results) -> list: + raise NotImplementedError + + +class GooseTranslator(Translator): + """Goose message rows → Trace (content blocks: text / toolRequest / toolResponse).""" + + agent = "goose" + root_name = "goose_conversation" + + @staticmethod + def _blocks(msg) -> list[dict]: + c = msg.get("content") + return c if isinstance(c, list) else [] + + def _is_prompt(self, rec) -> bool: + return rec.get("role") == _ROLE_USER and any( + b.get("type") == "text" for b in self._blocks(rec) + ) + + def _prompt_text(self, rec) -> str: + return "".join( + b.get("text", "") for b in self._blocks(rec) if b.get("type") == "text" + ) + + def _ts(self, rec) -> float | None: + return _parse_ts(rec.get("ts")) + + def _tool_results(self, records) -> dict: + results: dict = {} + for msg in records: + for b in self._blocks(msg): + if b.get("type") == "toolResponse" and (tid := b.get("id")): + results[tid] = self._result_text(b) + return results + + @staticmethod + def _result_text(block) -> object: + value = (block.get("toolResult") or {}).get("value") or {} + content = value.get("content") + if isinstance(content, list): + return "".join( + c.get("text", "") for c in content if c.get("type") == "text" + ) + return content if content is not None else "" + + def _events(self, rec, ts, results) -> list: + if rec.get("role") != _ROLE_ASSISTANT: + return [] + out = [] + for b in self._blocks(rec): + if b.get("type") == "text" and b.get("text", "").strip(): + out.append(("llm", ts, b["text"], None, None)) + elif b.get("type") == "toolRequest": + call = (b.get("toolCall") or {}).get("value") or {} + out.append( + ( + "tool", + ts, + call.get("name", "unknown"), + call.get("arguments", {}), + results.get(b.get("id"), ""), + ) + ) + return out + + +class OpenCodeTranslator(Translator): + """opencode flattened parts → Trace (call + result live in one tool part).""" + + agent = "opencode" + root_name = "opencode_conversation" + + def _is_prompt(self, rec) -> bool: + d = rec.get("data") or {} + return ( + rec.get("role") == _ROLE_USER + and d.get("type") == "text" + and bool(d.get("text", "").strip()) + ) + + def _prompt_text(self, rec) -> str: + return (rec.get("data") or {}).get("text", "") + + def _ts(self, rec) -> float | None: + return rec.get("ts") + + def _events(self, rec, ts, results) -> list: + d = rec.get("data") or {} + if ( + d.get("type") == "text" + and rec.get("role") == _ROLE_ASSISTANT + and d.get("text", "").strip() + ): + return [("llm", ts, d["text"], rec.get("model"), None)] + if d.get("type") == "tool": + state = d.get("state") or {} + return [ + ( + "tool", + ts, + d.get("tool", "unknown"), + state.get("input", {}), + state.get("output", ""), + ) + ] + return [] + + +_CLINE_USER_INPUT_RE = re.compile(r"]*>(.*?)", re.DOTALL) + + +class ClineTranslator(Translator): + """Cline message records → Trace (Anthropic block list; ms timestamps).""" + + agent = "cline" + root_name = "cline_conversation" + + @staticmethod + def _content(msg) -> list[dict]: + c = msg.get("content") + return c if isinstance(c, list) else [] + + def _join_text(self, msg) -> str: + return "".join( + b.get("text", "") for b in self._content(msg) if b.get("type") == "text" + ) + + def _is_prompt(self, rec) -> bool: + return rec.get("role") == _ROLE_USER and any( + b.get("type") == "text" and b.get("text", "").strip() + for b in self._content(rec) + ) + + def _prompt_text(self, rec) -> str: + text = self._join_text(rec) + found = _CLINE_USER_INPUT_RE.findall(text) + return "".join(found).strip() if found else text.strip() + + def _ts(self, rec) -> float | None: + ts = rec.get("ts") + return ts / 1000.0 if isinstance(ts, (int, float)) else None + + def _tool_results(self, records) -> dict: + results: dict = {} + for msg in records: + for b in self._content(msg): + if b.get("type") == "tool_result" and (tid := b.get("tool_use_id")): + results[tid] = self._result_text(b) + return results + + @staticmethod + def _result_text(block) -> object: + content = block.get("content") + if isinstance(content, list): + return "".join( + c.get("text", "") + for c in content + if isinstance(c, dict) and c.get("type") == "text" + ) + return content if content is not None else "" + + def _events(self, rec, ts, results) -> list: + if rec.get("role") != _ROLE_ASSISTANT: + return [] + out = [] + text = self._join_text(rec) + if text.strip(): + out.append(("llm", ts, text, rec.get("model"), None)) + for b in self._content(rec): + if b.get("type") == "tool_use": + out.append( + ( + "tool", + ts, + b.get("name", "unknown"), + b.get("input", {}), + results.get(b.get("id"), ""), + ) + ) + return out + + +class CodexTranslator(Translator): + """Codex rollout records → Trace (OpenAI Responses format). + + Normalizes the rollout into ``{ts, p}`` conversation items, then fits the + template — except the prompt is the *last* user message in a consecutive run + (Codex injects an AGENTS.md context message earlier), so ``_prompt_indices`` + is overridden with that lookahead. + """ + + agent = "codex" + root_name = "codex_conversation" + + def _normalize(self, records) -> list[dict]: + return [ + {"ts": _parse_ts(r.get("timestamp")), "p": r["payload"]} + for r in records + if r.get("type") == "response_item" and isinstance(r.get("payload"), dict) + ] + + def _ts(self, rec) -> float | None: + return rec["ts"] + + def _tool_results(self, records) -> dict: + results: dict = {} + for rec in records: + p = rec["p"] + if p.get("type") in ("function_call_output", "custom_tool_call_output"): + if cid := p.get("call_id"): + results[cid] = p.get("output", "") + return results + + def _prompt_indices(self, records) -> list[int]: + msg_idxs = [i for i, r in enumerate(records) if r["p"].get("type") == "message"] + prompts = [] + for pos, i in enumerate(msg_idxs): + if records[i]["p"].get("role") != _ROLE_USER: + continue + nxt = msg_idxs[pos + 1] if pos + 1 < len(msg_idxs) else None + if nxt is None or records[nxt]["p"].get("role") == _ROLE_ASSISTANT: + prompts.append(i) + return prompts + + def _prompt_text(self, rec) -> str: + return self._msg_text(rec["p"]) + + @staticmethod + def _msg_text(item) -> str: + return "".join( + b.get("text", "") + for b in (item.get("content") or []) + if isinstance(b, dict) and b.get("type") in ("input_text", "output_text") + ) + + def _events(self, rec, ts, results) -> list: + p = rec["p"] + ptype = p.get("type") + if ptype == "message" and p.get("role") == _ROLE_ASSISTANT: + text = self._msg_text(p) + return [("llm", ts, text, None, None)] if text.strip() else [] + if ptype in ("function_call", "custom_tool_call"): + name, tool_input = self._tool_call(p) + return [ + ("tool", ts, name, tool_input, results.get(p.get("call_id", ""), "")) + ] + return [] + + @staticmethod + def _tool_call(item) -> tuple[str, object]: + name = item.get("name", "unknown") + if item.get("type") == "function_call": + raw = item.get("arguments", "") + try: + return name, json.loads(raw) + except (json.JSONDecodeError, TypeError): + return name, raw + return name, item.get("input", "") # custom_tool_call: raw input string + + +class ClaudeTranslator(Translator): + """Claude transcript → Trace — bespoke; overrides the template. + + Claude emits separate entries per thinking / text / tool_use, folds queued + "steer" messages into the request window, splits a turn's duration across + multiple tool calls, and carries token usage — richer than the event + template, so it builds spans directly via ``Trace``'s ``add_*`` methods. + """ + + agent = "claude" + + def translate(self, records, *, trace_id, session_id, project) -> Trace | None: + starts = self._prompt_indices(records) + if not starts: + return None + trace = Trace(trace_id, session_id, self.agent, project) + bounds = starts + [len(records)] + for k, user_idx in enumerate(starts): + self._build_turn(trace, records, user_idx, bounds[k + 1]) + return trace if trace.spans else None + + # _build_turn here takes no results arg (Claude looks results up per turn), + # so it intentionally shadows the template's signature. + def _build_turn(self, trace, records, user_idx, end_idx) -> None: # type: ignore[override] + user_rec = records[user_idx] + user_msg = (user_rec.get("message") or {}).get("content", "") + prompt = ( + user_msg if isinstance(user_msg, str) else self._text_and_tools(user_msg)[0] + ) + root_id = user_rec.get("uuid") or f"turn-{user_idx}" + root_ts = _parse_ts(user_rec.get("timestamp")) + root = trace.add_agent_root( + root_id, "claude_code_conversation", start_time=root_ts, prompt=prompt + ) + + counter = 0 + final_response: str | None = None + last_ts = root_ts + for i in range(user_idx + 1, end_idx): + rec = records[i] + if (t := _parse_ts(rec.get("timestamp"))) is not None: + last_ts = t + if rec.get("type") != _ROLE_ASSISTANT: + continue + msg = rec.get("message") or {} + ts = _parse_ts(rec.get("timestamp")) + text, tools = self._text_and_tools(msg.get("content")) + nxt = self._next_timestamp(records, i, stop=end_idx) + duration = ( + (nxt - ts) + if (ts is not None and nxt is not None and nxt > ts) + else _DEFAULT_SPAN_SECONDS + ) + + if text.strip() and not tools: + final_response = text + trace.add_llm_span( + f"{root_id}-{counter}", + parent_id=root_id, + start_time=ts, + end_time=(ts + duration) if ts is not None else None, + request=self._window_messages(records, i), + response=[_text_block(text)], + model=msg.get("model"), + usage=_usage(msg.get("usage")), + ) + counter += 1 + + if tools: + results = self._tool_results_after(records, i) + tool_duration = duration / len(tools) + for idx_t, tu in enumerate(tools): + tid = tu.get("id", "") + t_start = (ts + idx_t * tool_duration) if ts is not None else None + trace.add_tool_span( + f"{root_id}-{counter}", + parent_id=root_id, + start_time=t_start, + end_time=( + (t_start + tool_duration) if t_start is not None else None + ), + name=tu.get("name", "unknown"), + tool_input=tu.get("input", {}), + result=results.get(tid, ""), + tool_id=tid, + ) + counter += 1 + + root["end_time"] = last_ts + if final_response is not None: + root["attributes"]["response"] = final_response + + # -- Claude grammar (ported from mlflow claude_code; see NOTICE) --------- + + def _prompt_indices(self, records) -> list[int]: + return [i for i in range(len(records)) if self._is_user_prompt(records, i)] + + @staticmethod + def _is_user_prompt(records, i) -> bool: + rec = records[i] + if rec.get("type") != _ROLE_USER or rec.get("toolUseResult"): + return False + prev = records[i - 1] if i > 0 else None + prev_tur = prev.get("toolUseResult") if isinstance(prev, dict) else None + if isinstance(prev_tur, dict) and prev_tur.get("commandName"): + return False + content = (rec.get("message") or {}).get("content") + if isinstance(content, list) and content and isinstance(content[0], dict): + if content[0].get("type") == "tool_result": + return False + if isinstance(content, str): + if "" in content or not content.strip(): + return False + return bool(content) + + @staticmethod + def _text_and_tools(content) -> tuple[str, list[dict]]: + text, tools = "", [] + if isinstance(content, list): + for b in content: + if not isinstance(b, dict): + continue + if b.get("type") == "text": + text += b.get("text", "") + elif b.get("type") == "tool_use": + tools.append(b) + elif isinstance(content, str): + text = content + return text, tools + + @staticmethod + def _next_timestamp(records, idx, stop=None) -> float | None: + end = len(records) if stop is None else stop + for j in range(idx + 1, end): + if (t := _parse_ts(records[j].get("timestamp"))) is not None: + return t + return None + + def _block_from_raw(self, raw) -> dict | None: + btype = raw.get("type") + if btype == "text": + return _text_block(raw.get("text", "")) + if btype == "tool_use": + return _tool_use_block( + raw.get("name"), raw.get("input") or {}, raw.get("id") + ) + if btype == "tool_result": + return _tool_result_block(raw.get("content"), raw.get("tool_use_id")) + return None # thinking / unknown — no conversational payload + + def _message_from_record(self, record) -> dict | None: + msg = record.get("message") or {} + role = msg.get("role") + content = msg.get("content") + if not role or not content: + return None + if isinstance(content, str): + return _message(role, [_text_block(content)]) + blocks = [b for raw in content if (b := self._block_from_raw(raw))] + return _message(role, blocks) if blocks else None + + def _window_messages(self, records, current_idx) -> list[dict]: + """The user/tool entries since the previous text-bearing assistant turn.""" + out: list[dict] = [] + for i in range(current_idx - 1, -1, -1): + rec = records[i] + if rec.get("type") == _ROLE_ASSISTANT: + text, _ = self._text_and_tools( + (rec.get("message") or {}).get("content") + ) + if text.strip(): + break + if ( + rec.get("type") == _TYPE_QUEUE_OP + and rec.get("operation") == "enqueue" + and (steer := rec.get("content")) + ): + txt = steer if isinstance(steer, str) else str(steer) + out.append(_message(_ROLE_USER, [_text_block(txt)])) + continue + if (m := self._message_from_record(rec)) is not None: + out.append(m) + out.reverse() + return out + + @staticmethod + def _tool_results_after(records, start_idx) -> dict: + results: dict = {} + for i in range(start_idx + 1, len(records)): + rec = records[i] + if rec.get("type") != _ROLE_USER: + continue + content = (rec.get("message") or {}).get("content") + if isinstance(content, list): + for b in content: + if isinstance(b, dict) and b.get("type") == "tool_result": + if tid := b.get("tool_use_id"): + results[tid] = b.get("content", "") + return results + + +# =========================================================================== +# TraceCollector — the driver (read → translate → hand to consumers) +# =========================================================================== + +# An agent appears here once both its reader and translator exist; the collector +# runs for any agent in the table and is simply absent for the rest. +_PIPELINES = { + "claude": lambda pd, pr: (ClaudeReader(pd, projects_root=pr), ClaudeTranslator()), + "codex": lambda pd, pr: (CodexReader(pd), CodexTranslator()), + "goose": lambda pd, pr: (GooseReader(pd), GooseTranslator()), + "opencode": lambda pd, pr: (OpenCodeReader(pd), OpenCodeTranslator()), + "cline": lambda pd, pr: (ClineReader(pd), ClineTranslator()), +} + + +def make_trace_collector( + agent, + project_dir, + project, + session_id, + tracking_uri, + *, + projects_root: Path | None = None, + extra_consumers: list | None = None, +) -> "TraceCollector | None": + """Build the collector for ``agent``, or ``None`` if no pipeline is registered. + + The MLflow logger is always the first consumer (observability is universal); + ``extra_consumers`` (e.g. a :class:`~dsagt.memory.MemoryExtractor` when + episodic memory is enabled) are appended, each acking independently. + """ + builder = _PIPELINES.get(agent) + if builder is None: + return None + reader, translator = builder(project_dir, projects_root) + # Imported here (not at module top) so traces stays a lean leaf — the MLflow + # logger drags in mlflow, the heaviest thing in the pipeline. + from dsagt.observability import MLflowSink + + consumers = [MLflowSink(tracking_uri, project), *(extra_consumers or [])] + return TraceCollector( + reader, + translator, + project=project, + session_id=session_id, + project_dir=project_dir, + consumers=consumers, + ) + + +class TraceCollector: + """Periodically read the session, translate it, and hand it to consumers. + + A *consumer* is anything with a ``name`` and a ``write(trace)`` — the MLflow + logger and the memory distiller both qualify (no shared base needed). Each + consumer keeps its own ack set (``.dsagt/trace_acks_.json``), so a + re-pass or an N+1 catch-up can only waste work, never double-log or lose a + turn, and a failing consumer holds back only its own mark. + + Completeness watermark: a periodic pass emits only *completed* turns (all but + the still-open last one); the deferred final turn flushes when a later prompt + bounds it or at end-of-session (``include_last=True``). An OS file lock + serializes overlapping passes against the shared ack files. + """ + + def __init__( + self, reader, translator, *, project, session_id, project_dir, consumers + ): + self._reader = reader + self._translator = translator + self._project = project + self._session_id = session_id + self._project_dir = Path(project_dir) + self._consumers = list(consumers) + self._dsagt_dir = self._project_dir / ".dsagt" + self._lock = threading.Lock() + + def _acks_path(self, name: str) -> Path: + return self._dsagt_dir / f"trace_acks_{name}.json" + + def _load_acks(self, name: str) -> set[str]: + try: + return set(json.loads(self._acks_path(name).read_text())) + except FileNotFoundError: + return set() + + def _save_acks(self, name: str, acks: set[str]) -> None: + self._dsagt_dir.mkdir(parents=True, exist_ok=True) + self._acks_path(name).write_text(json.dumps(sorted(acks))) + + @contextmanager + def _lock_file(self): + self._dsagt_dir.mkdir(parents=True, exist_ok=True) + with open(self._dsagt_dir / "trace_acks.lock", "w") as lf: + fcntl.flock(lf, fcntl.LOCK_EX) + try: + yield + finally: + fcntl.flock(lf, fcntl.LOCK_UN) + + def collect(self, *, include_last: bool = False) -> int: + """Read → translate → hand completed turns to each consumer. + + Returns the number of turns newly delivered to at least one consumer. + ``include_last=True`` (end-of-session flush / per-turn hook) also emits + the otherwise-deferred final turn. Blocking — call via + ``asyncio.to_thread`` from the event loop. + """ + with self._lock, self._lock_file(): + records = self._reader.read() + if not records: + return 0 + trace = self._translator.translate( + records, + trace_id=self._session_id, + session_id=self._session_id, + project=self._project, + ) + if trace is None: + return 0 + + roots = trace.roots() + candidates = roots if include_last else roots[:-1] + candidate_ids = [r["span_id"] for r in candidates] + if not candidate_ids: + return 0 + + emitted: set[str] = set() + for consumer in self._consumers: + acks = self._load_acks(consumer.name) + emit_ids = {i for i in candidate_ids if i not in acks} + if not emit_ids: + continue + try: + consumer.write(trace.subset(emit_ids)) + except Exception as e: # noqa: BLE001 — per-consumer isolation + logger.warning("Trace consumer %r failed: %s", consumer.name, e) + continue + self._save_acks(consumer.name, acks | emit_ids) + emitted |= emit_ids + return len(emitted) diff --git a/tests/conftest.py b/tests/conftest.py index 3020f45..8ed7715 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,26 +23,6 @@ _ENV_PATH = Path(__file__).parent.parent / ".env" -@pytest.fixture(autouse=True) -def _stub_mlflow_experiment_lookup(monkeypatch): - """Short-circuit ``_resolve_experiment_id`` in unit tests. - - Most tests pass ``mlflow.port=5001`` but don't actually run an MLflow - server. Real ``mlflow.set_experiment`` would block on the unreachable - URL until socket timeout (tens of seconds × every test that builds - ``agent_env``). Returning a fake numeric id keeps the env block - populated without paying for the lookup. - - Tests that DO run a real MLflow (the integration suite, smoke tests) - pass through unchanged because ``mlflow.set_experiment`` is the only - thing this stubs — the real network calls happen elsewhere. - """ - monkeypatch.setattr( - "dsagt.agents._resolve_experiment_id", - lambda mlflow_url, project_name: "1", - ) - - def _load_env() -> dict: """Parse .env as KEY=VALUE lines. Fails if the file is missing. @@ -74,7 +54,7 @@ def _require(env: dict, key: str) -> str: @pytest.fixture def embedding_config(monkeypatch): - """Embedding endpoint from .env. Sets env vars for APIEmbeddingClient.""" + """Embedding endpoint from .env. Sets env vars for APIEmbedder.""" env = _load_env() base_url = _require(env, "EMBEDDING_BASE_URL") api_key = _require(env, "EMBEDDING_API_KEY") @@ -103,8 +83,8 @@ def llm_config(): def integration_config(tmp_path, monkeypatch): """Full DSAGT config as ``load_config()`` would return it. - Builds a dsagt_config.yaml in a temp project dir from .env values, then - patches the registry so ``load_config(project_name)`` resolves to it. + Builds a ``.dsagt/config.yaml`` in a temp project dir from .env values, + then patches the registry so ``load_config(project_name)`` resolves to it. """ from dsagt.session import load_config @@ -113,26 +93,25 @@ def integration_config(tmp_path, monkeypatch): config_data = { "project": project_name, "agent": "claude", - "llm": { - "provider": _require(env, "LLM_PROVIDER"), - "model": _require(env, "LLM_MODEL"), - "base_url": _require(env, "LLM_BASE_URL"), - "api_key": _require(env, "LLM_API_KEY"), - }, "embedding": { + "backend": "api", "model": _require(env, "EMBEDDING_MODEL"), "base_url": _require(env, "EMBEDDING_BASE_URL"), - "api_key": _require(env, "EMBEDDING_API_KEY"), }, - "proxy": {"port": 14000}, - "mlflow": {"port": 15001, "backend": "sqlite"}, } project_dir = tmp_path / project_name project_dir.mkdir(parents=True) - for subdir in ("trace_archive", "mlflow", "tools", "tools/code", "skills", "kb_index"): + for subdir in ( + "trace_archive", + "tools", + "tools/code", + "skills", + "kb_index", + ".dsagt", + ): (project_dir / subdir).mkdir() - (project_dir / "dsagt_config.yaml").write_text( + (project_dir / ".dsagt" / "config.yaml").write_text( yaml.dump(config_data, default_flow_style=False, sort_keys=False) ) diff --git a/tests/mcp_helpers.py b/tests/mcp_helpers.py index 583e414..007e371 100644 --- a/tests/mcp_helpers.py +++ b/tests/mcp_helpers.py @@ -14,11 +14,11 @@ import mcp.types as types - # --------------------------------------------------------------------------- # In-process MCP tool invocation (for unit tests) # --------------------------------------------------------------------------- + def call_tool_sync(server, name: str, arguments: dict) -> str: """Invoke a tool handler on an MCP server and return the response text.""" req = types.CallToolRequest( @@ -105,49 +105,62 @@ def read_mcp_message(proc, timeout: float = 10.0, expect_id=None) -> dict: def mcp_initialize(proc) -> dict: """Send MCP initialize handshake and return the server's response.""" - send_mcp_message(proc, { - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "protocolVersion": "2024-11-05", - "capabilities": {}, - "clientInfo": {"name": "test", "version": "1.0"}, + send_mcp_message( + proc, + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "test", "version": "1.0"}, + }, }, - }) + ) response = read_mcp_message(proc, expect_id=1) - send_mcp_message(proc, { - "jsonrpc": "2.0", - "method": "notifications/initialized", - }) + send_mcp_message( + proc, + { + "jsonrpc": "2.0", + "method": "notifications/initialized", + }, + ) return response def mcp_list_tools(proc) -> dict: """Request tools/list and return the response.""" - send_mcp_message(proc, { - "jsonrpc": "2.0", - "id": 2, - "method": "tools/list", - "params": {}, - }) + send_mcp_message( + proc, + { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": {}, + }, + ) return read_mcp_message(proc, expect_id=2) -def mcp_call_tool(proc, tool_name: str, arguments: dict, - msg_id: int = 3, timeout: float = 30.0) -> dict: +def mcp_call_tool( + proc, tool_name: str, arguments: dict, msg_id: int = 3, timeout: float = 30.0 +) -> dict: """Call an MCP tool and return the JSON-RPC response.""" - send_mcp_message(proc, { - "jsonrpc": "2.0", - "id": msg_id, - "method": "tools/call", - "params": { - "name": tool_name, - "arguments": arguments, + send_mcp_message( + proc, + { + "jsonrpc": "2.0", + "id": msg_id, + "method": "tools/call", + "params": { + "name": tool_name, + "arguments": arguments, + }, }, - }) + ) return read_mcp_message(proc, timeout=timeout, expect_id=msg_id) diff --git a/tests/smoke_test/greet.py b/tests/smoke_test/greet.py index 1ca5f46..30f83b0 100755 --- a/tests/smoke_test/greet.py +++ b/tests/smoke_test/greet.py @@ -1,10 +1,13 @@ """Simple greeting script for smoke testing.""" + import argparse import json parser = argparse.ArgumentParser(description="Generate a greeting") parser.add_argument("name", help="Name to greet") -parser.add_argument("--greeting", default="Hello", help="Greeting word (default: Hello)") +parser.add_argument( + "--greeting", default="Hello", help="Greeting word (default: Hello)" +) args = parser.parse_args() result = {"message": f"{args.greeting}, {args.name}!", "status": "ok"} diff --git a/tests/smoke_test/manual_runs/cli_walkthrough.md b/tests/smoke_test/manual_runs/cli_walkthrough.md index 72a82a6..2814e86 100644 --- a/tests/smoke_test/manual_runs/cli_walkthrough.md +++ b/tests/smoke_test/manual_runs/cli_walkthrough.md @@ -1,6 +1,6 @@ # BYOA CLI hand-test -Tests goose, codex, opencode, and claude in the BYOA-CLI flow (no proxy, no IDE extension). +Tests goose, codex, opencode, and claude in the BYOA-CLI flow (no IDE extension). Replace `` below with one of: `goose`, `codex`, `opencode`, `claude`. diff --git a/tests/smoke_test/manual_runs/proxy_walkthrough.md b/tests/smoke_test/manual_runs/proxy_walkthrough.md deleted file mode 100644 index 55340f9..0000000 --- a/tests/smoke_test/manual_runs/proxy_walkthrough.md +++ /dev/null @@ -1,109 +0,0 @@ -# Proxy mode hand-test - -Tests goose, claude, roo, cline, codex, opencode through `dsagt start --enable-proxy`. - -The proxy intercepts the agent's LLM calls, translates lab-gateway-aliased model names back to upstream-served names (`_AGENT_PRIMARY_ALIASES`), forwards via LiteLLM to the configured upstream, and emits OTLP spans to MLflow tagged with `service.name=dsagt-proxy`. Plus cache-breakpoint injection on outgoing requests + sidechannel detection on responses. - -Replace `` below with one of: `goose`, `claude`, `roo`, `cline`, `codex`, `opencode`. - -## Setup (once per agent) - -```bash -cd ~/dsagt -source .venv/bin/activate -export SMOKE_DIR="$(pwd)/tests/smoke_test" - -# Proxy mode reads upstream creds from .env (or shell env). These four -# are required: -export LLM_PROVIDER=openai -export LLM_MODEL=claude-haiku-4-5-20251001-v1-project -export LLM_BASE_URL=https://ai-incubator-api.pnnl.gov -export LLM_API_KEY=sk-... -# If `embedding.backend: api` in your project config, also set -# EMBEDDING_PROVIDER / EMBEDDING_MODEL / EMBEDDING_BASE_URL / -# EMBEDDING_API_KEY. Default `local` mode needs none of these. - -dsagt rm smoke- -y >/dev/null 2>&1 -dsagt init smoke- --agent -``` - -## Run - -```bash -# Terminal 1 -dsagt mlflow smoke- - -# Terminal 2 -dsagt start smoke- --enable-proxy -``` - -This spawns: -- MLflow on the pinned port (from `dsagt_config.yaml`) -- `dsagt-proxy` on a free port (its YAML config is regenerated each run) -- The agent, with its env / config files routed at the proxy URL + a sentinel API key (real upstream creds live only in the proxy subprocess) - -## Scripted prompts (paste one at a time) - -Same six prompts as `cli_walkthrough.md`: - -1. > Ingest the docs in `$SMOKE_DIR/knowledge/` into a collection named `knowledge`. -2. > I have a CSV utility called `csvtool`. Its reference is at `$SMOKE_DIR/knowledge/api_reference.md` — register the `filter` subcommand. Use an underscore in the name. -3. > Use the `scan_directory` tool from the registry to scan `$SMOKE_DIR/data/`. -4. > Look at `$SMOKE_DIR/data/samples.csv` and summarize — columns, row count, quality issues. -5. > Put this in explicit memory: samples.csv has null values in the status and timestamp columns. -6. > What do you remember about the samples dataset? - -Exit the agent. - -## Verify - -```bash -dsagt info smoke- -ls ~/dsagt-projects/smoke-/tools/ -ls ~/dsagt-projects/smoke-/trace_archive/ -test -s ~/dsagt-projects/smoke-/explicit_memories.yaml && echo OK -``` - -In MLflow UI: - -- **All six agents:** agent LLM-call traces with `service.name = "dsagt-proxy"`, full request `messages` + response payloads (LiteLLM autolog shape), token counts, latency. -- Plus the usual MCP / dsagt-run spans (separate `service.name`s). - -If the agent emits its own OTel too (claude/goose), you'll see those spans alongside the dsagt-proxy ones — same trace store, different service names. - -## Sidechannel warnings - -At end-of-session, if any agent-internal "title generator" / "session namer" calls fired (model names not in your `LLM_MODEL`), the teardown prints: - -``` - ⚠ Sidechannel model calls intercepted: - gpt-4o-mini (2 calls) - Two possible causes: - (1) agent sidechannel — safe to ignore - (2) typo in dsagt_config.yaml llm.model — these replies are canned, not real -``` - -Confirms the wildcard catchall + canned-response mock fired correctly. - -## Per-agent expected behavior - -| Agent | What proxy mode adds beyond BYOA | -|---|---| -| goose | Uniform trace shape; MLflow now has every LLM call from one parser-friendly source | -| claude | Sidesteps the macOS Keychain OAuth conflict — claude's auth token gets ignored by the proxy, which uses `LLM_API_KEY` for upstream | -| roo | **Un-punted.** Roo's anthropic SDK posts to `ANTHROPIC_BASE_URL` (the proxy URL); the proxy aliases roo's hardcoded `claude-sonnet-4-5` rewrite back to your upstream model | -| cline | **Un-punted.** `cline auth -p openai -b ` configures cline to talk openai-shape to the proxy; the proxy translates and aliases | -| codex | LLM-call message payloads now visible (codex's bundled OTel only emits token counts) | -| opencode | LLM-call traces visible (opencode emits no native OTel at all) | - -## Verify proxy logs - -```bash -tail -50 ~/dsagt-projects/smoke-/proxy.log -``` - -Look for: -- `init_proxy_tracing: service=dsagt-proxy mlflow=...` -- `Generated LiteLLM config at /tmp/dsagt_litellm_*.yaml` -- `Uvicorn running on http://0.0.0.0:` -- One `litellm` log line per agent LLM call diff --git a/tests/smoke_test/manual_runs/vscode_walkthrough.md b/tests/smoke_test/manual_runs/vscode_walkthrough.md index 8349bb8..49ea54c 100644 --- a/tests/smoke_test/manual_runs/vscode_walkthrough.md +++ b/tests/smoke_test/manual_runs/vscode_walkthrough.md @@ -61,7 +61,7 @@ test -s ~/dsagt-projects/smoke-/explicit_memories.yaml && echo OK In MLflow UI: - **claude extension:** same trace shape as claude CLI — full `api_response_body` + `tool_use` payloads in OTel log events (gated by `CLAUDE_CODE_*` / `OTEL_LOG_*` env vars; the extension reads them from the shell or VS Code's `terminal.integrated.env.osx` setting). -- **roo extension:** no agent LLM-call OTel (roo emits none natively). MCP-server spans (`kb.*`, `registry.*`) and `dsagt-run` `tool.execute` spans only. For LLM-call visibility with roo, switch to the proxy walkthrough. +- **roo extension:** no agent LLM-call OTel (roo emits none natively). MCP-server spans (`kb.*`, `registry.*`) and `dsagt-run` `tool.execute` spans only. ## Notes diff --git a/tests/smoke_test/run.sh b/tests/smoke_test/run.sh index 8914eca..bb8ee8b 100755 --- a/tests/smoke_test/run.sh +++ b/tests/smoke_test/run.sh @@ -5,8 +5,7 @@ # generation → start_services → agent → run_extraction → stop_services). # Only the agent-launch step swaps from `goose session` to `goose run -i`. # BYOA: the user's shell must already have the agent's provider creds -# (per `dsagt init` hints). No .env handling — that returns with the -# Phase 2 `--proxy_traces` flag. +# (per `dsagt init` hints). No .env handling. # # Run from anywhere: # bash tests/smoke_test/run.sh @@ -19,7 +18,7 @@ set -uo pipefail AGENT="${DSAGT_SMOKE_AGENT:-${1:-goose}}" # arg or env var, default goose # Per-agent project name so each agent's mlflow.db, trace_archive, and # kb_index/ survive across runs — crucial for cross-agent comparison -# (e.g., why does claude use 10x the tokens roo does?). Without this, +# (e.g., why does claude use 10x the tokens codex does?). Without this, # `dsagt rm` at the start of each run wipes the previous agent's state. PROJECT="smoke-test-${AGENT}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -31,55 +30,20 @@ SCRIPT_FILE="${SCRIPT_DIR}/script.txt" PDIR="${HOME}/dsagt-projects/${PROJECT}" case "${AGENT}" in - goose|claude|cline|roo|codex|opencode) ;; + goose|claude|cline|codex|opencode) ;; *) - echo "ERROR: agent must be one of: goose, claude, cline, roo, codex, opencode (got '${AGENT}')" >&2 + echo "ERROR: agent must be one of: goose, claude, cline, codex, opencode (got '${AGENT}')" >&2 exit 2 ;; esac -# Proxy-mode opt-in. ``DSAGT_SMOKE_PROXY=1`` (or arg #2 == "proxy") -# routes the run through ``dsagt start --enable-proxy``, which spawns -# dsagt-proxy and forwards the agent's LLM calls through it — required -# for cline / roo (their CLIs hardcode model-ID whitelists incompatible -# with lab-gateway aliases; the proxy translates names back to the -# upstream's served IDs via _AGENT_PRIMARY_ALIASES). Default off. -PROXY_FLAG="" -if [[ "${DSAGT_SMOKE_PROXY:-${2:-}}" == "1" || "${DSAGT_SMOKE_PROXY:-${2:-}}" == "proxy" ]]; then - PROXY_FLAG="--enable-proxy" - echo "[smoke] Agent: ${AGENT} (proxy mode)" -else - echo "[smoke] Agent: ${AGENT}" -fi - -# Cline / roo only work in proxy mode (see agents/cline.py + roo.py -# module docstrings for the model-whitelist + endpoint-lockout reasons). -if [[ -z "${PROXY_FLAG}" && ( "${AGENT}" == "cline" || "${AGENT}" == "roo" ) ]]; then - echo - echo "[smoke] ${AGENT} requires proxy mode in BYOA — skipping." - echo "[smoke] Re-run with: DSAGT_SMOKE_PROXY=1 dsagt smoke-test --agent ${AGENT}" - echo "[smoke] (or pass 'proxy' as the second arg to this script)" - exit 0 -fi +echo "[smoke] Agent: ${AGENT}" cd "${DSAGT_ROOT}" -# Proxy mode needs LLM_*/EMBEDDING_* in env so the proxy can forward -# upstream. Source .env if present (BYOA runs without it; only proxy -# runs need these vars). Validation happens at proxy spawn time — -# session.py:_start_proxy raises if config["llm"] keys are missing. -if [[ -n "${PROXY_FLAG}" && -f .env ]]; then - set -a - # shellcheck disable=SC1091 - source .env - set +a - echo "[smoke] Sourced .env for proxy mode" -fi - # --------------------------------------------------------------------------- # 2. Clean slate (idempotent — silent if nothing exists) # --------------------------------------------------------------------------- -dsagt stop "${PROJECT}" >/dev/null 2>&1 || true dsagt rm "${PROJECT}" -y >/dev/null 2>&1 || true rm -rf "${PDIR}" @@ -101,7 +65,9 @@ fi # that we substitute below so prompt paths resolve regardless of where # PDIR lives. # --------------------------------------------------------------------------- -dsagt init "${PROJECT}" --agent "${AGENT}" +# Force the non-interactive (flag-driven) init path regardless of TTY by +# closing stdin — `dsagt init` prompts only when stdin is a TTY. +dsagt init "${PROJECT}" --agent "${AGENT}" < /dev/null # Substitute {{SMOKE_DIR}} → absolute smoke_test/ path before the agent # sees the script. Prompts can then reference data/knowledge files via @@ -124,8 +90,8 @@ WALL_CLOCK_CAP=300 # seconds (5 minutes) WALL_CLOCK_GRACE=10 # extra seconds before SIGKILL echo -echo "[smoke] Running dsagt start --script ${PROXY_FLAG} (${WALL_CLOCK_CAP}s wall-clock cap)…" -dsagt start "${PROJECT}" --script "${RENDERED_SCRIPT}" --max-turns 30 ${PROXY_FLAG} & +echo "[smoke] Running dsagt start --script (${WALL_CLOCK_CAP}s wall-clock cap)…" +dsagt start "${PROJECT}" --script "${RENDERED_SCRIPT}" --max-turns 30 & DSAGT_PID=$! ( sleep "${WALL_CLOCK_CAP}" @@ -146,13 +112,8 @@ if [[ ${START_EXIT} -ne 0 ]]; then echo "WARN: dsagt start exited non-zero (${START_EXIT}) — continuing to artifact checks anyway" fi -# Defensive: ensure no stray services if the lifecycle's finally didn't run -# (timeout SIGKILL skips Python's finally blocks). Output kept visible — -# if any port is still in use after this, we want to see the warning so -# the next run doesn't race a half-shutdown orphan. -echo -echo "[smoke] Final cleanup…" -dsagt stop "${PROJECT}" || true +# Serverless: ``dsagt start`` runs the agent in the foreground and owns no +# background services, so there's nothing to reap here. # --------------------------------------------------------------------------- # 5. Artifact checks @@ -189,23 +150,24 @@ check "knowledge ingested (vectors)" "test -f '${PDIR}/kb_index/knowledge/chroma # the YAML's existence + non-empty catches the hallucination case where # the agent claims it stored a fact but didn't actually call the tool. check "explicit memory recorded" "test -s '${PDIR}/explicit_memories.yaml'" -check "mlflow has traces" "test -s '${PDIR}/mlflow/mlflow.db'" +check "mlflow store has traces" "test -s '${PDIR}/mlflow.db'" # --------------------------------------------------------------------------- -# 6. Agent LLM-call observability: every agent turn must produce at least -# one trace in MLflow with this session's id, tagged with the agent's -# service.name. Replaces the proxy-log parity check from before -# proxy removal — the agent now emits OTel directly to MLflow's OTLP -# receiver, so MLflow IS the source of truth. A zero count means -# either the agent didn't emit telemetry (e.g. CLAUDE_CODE_ENABLE_TELEMETRY=1 -# not honored) or the OTel endpoint was misconfigured. +# 6. Agent LLM-call observability: informational only in Phase 1. +# DSAGT no longer forces native agent OTel emission (the OTLP-routing +# env + telemetry flags were removed) — agent LLM-call history is +# recovered post-hoc from the on-disk transcript, which lands in +# Phase 2's TracePipeline. Until then only claude (via the +# ``mlflow autolog claude`` Stop hook over its transcript) and DSAGT's +# own MCP / dsagt-run spans populate the serverless ``mlflow.db`` store. +# So we report the agent-trace count but never FAIL on it here. # --------------------------------------------------------------------------- AGENT_OTEL_SUPPORT=$(uv run --quiet python -c "from dsagt.agents import agent_otel_support; print(agent_otel_support('${AGENT}'))" 2>/dev/null) AGENT_OTEL_SUPPORT="${AGENT_OTEL_SUPPORT:-unknown}" AGENT_TRACES=$(uv run --quiet python </dev/null import mlflow -mlflow.set_tracking_uri("sqlite:///${PDIR}/mlflow/mlflow.db") +mlflow.set_tracking_uri("sqlite:///${PDIR}/mlflow.db") exp = mlflow.get_experiment_by_name("${PROJECT}") if exp is None: print(0); raise SystemExit @@ -232,30 +194,13 @@ PY ) AGENT_TRACES="${AGENT_TRACES:-0}" -# Grade by the agent's verified support tier rather than fail-or-pass: -# agents we know don't emit OTel payloads (cline, roo) get SKIP, agents -# we know partial-emit (codex) get WARN, agents we know full-emit -# (claude, goose) get PASS-or-FAIL. See agents/__init__.py module -# docstring for the matrix. -case "${AGENT_OTEL_SUPPORT}" in - full) - if [[ "${AGENT_TRACES}" -eq 0 ]]; then - echo " FAIL agent transparency: 0 agent LLM-call traces in MLflow (agent supports full payload but emitted none — env vars not honored?)" - FAIL=1 - else - echo " PASS agent transparency: ${AGENT_TRACES} agent LLM-call trace(s) visible in MLflow" - fi - ;; - partial) - echo " WARN agent transparency: ${AGENT} emits only token counts + tool names natively (${AGENT_TRACES} agent trace(s)); use 'dsagt start --enable-proxy' to capture full LLM-call payloads" - ;; - none) - echo " SKIP agent transparency: ${AGENT} emits no payload-bearing OTel traces (${AGENT_TRACES} agent trace(s)); use 'dsagt start --enable-proxy' to make agent LLM calls visible in MLflow" - ;; - *) - echo " WARN agent transparency: support tier unknown for ${AGENT}; ${AGENT_TRACES} agent trace(s)" - ;; -esac +# Phase 1: informational only — native OTel harvest was removed, transcript +# capture lands in Phase 2. Never FAIL on the agent-trace count here. +if [[ "${AGENT_TRACES}" -gt 0 ]]; then + echo " INFO agent transparency: ${AGENT_TRACES} agent trace(s) in the mlflow.db store (claude autolog / Phase-2 transcript capture)" +else + echo " INFO agent transparency: 0 agent LLM-call traces yet (${AGENT}, tier=${AGENT_OTEL_SUPPORT}) — restored by Phase 2's transcript pipeline" +fi echo if [[ ${FAIL} -eq 0 ]]; then diff --git a/tests/test_chroma_metadata.py b/tests/test_chroma_metadata.py index c468262..e928114 100644 --- a/tests/test_chroma_metadata.py +++ b/tests/test_chroma_metadata.py @@ -15,16 +15,14 @@ from dsagt.knowledge import ( ChromaIndex, - CollectionRoute, - FAISSIndex, KnowledgeBase, ) - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- + def fake_embed(texts: list[str]) -> np.ndarray: """Deterministic fake embeddings: hash-based so different texts get different (but reproducible) vectors.""" @@ -44,6 +42,7 @@ def fake_embed(texts: list[str]) -> np.ndarray: # ChromaIndex — metadata on add # --------------------------------------------------------------------------- + class TestChromaIndexMetadataAdd: def test_add_without_metadata(self): @@ -90,6 +89,7 @@ def test_add_metadata_incremental(self): # ChromaIndex — where filter on search # --------------------------------------------------------------------------- + class TestChromaIndexWhereSearch: @pytest.fixture @@ -127,7 +127,8 @@ def test_search_with_compound_where(self, indexed): query = np.ones(8, dtype=np.float32) query /= np.linalg.norm(query) scores, indices = indexed.search( - query, k=4, + query, + k=4, where={"$and": [{"tool_name": "megahit"}, {"session_id": "s2"}]}, ) assert set(indices.tolist()) == {3} @@ -140,7 +141,9 @@ def test_search_where_no_matches(self, indexed): # or raises when where matches nothing. Both are acceptable. try: scores, indices = indexed.search( - query, k=4, where={"tool_name": "nonexistent"}, + query, + k=4, + where={"tool_name": "nonexistent"}, ) assert len(scores) == 0 assert len(indices) == 0 @@ -160,6 +163,7 @@ def test_search_empty_index(self): # ChromaIndex — persistence with metadata # --------------------------------------------------------------------------- + class TestChromaIndexPersistence: def test_save_load_preserves_metadata(self, tmp_path): @@ -181,29 +185,11 @@ def test_save_load_preserves_metadata(self, tmp_path): assert results["metadatas"][1]["tool"] == "b" -# --------------------------------------------------------------------------- -# FAISSIndex — unchanged behavior -# --------------------------------------------------------------------------- - -class TestFAISSUnchanged: - - def test_faiss_add_search_unchanged(self): - """FAISSIndex add/search signatures are unchanged.""" - idx = FAISSIndex() - emb = np.random.randn(3, 8).astype(np.float32) - # Normalize for inner-product search - emb /= np.linalg.norm(emb, axis=1, keepdims=True) - idx.add(emb) - assert idx.size == 3 - - scores, indices = idx.search(emb[0], k=2) - assert len(scores) == 2 - - # --------------------------------------------------------------------------- # KnowledgeBase.search — where parameter # --------------------------------------------------------------------------- + class TestKnowledgeBaseSearchWhere: @pytest.fixture @@ -212,17 +198,12 @@ def kb_with_chroma(self, tmp_path): entries with metadata.""" index_dir = tmp_path / "kb" - chroma_route = CollectionRoute( - embedding_backend="api", - vector_db="chroma", - ) - - with patch("dsagt.knowledge._make_embedder") as mock_make: + with patch("dsagt.knowledge.Embedder.create") as mock_make: mock_embedder = MagicMock() mock_embedder.embed = fake_embed mock_make.return_value = mock_embedder - kb = KnowledgeBase(index_dir=index_dir, routes={"mem": chroma_route}) + kb = KnowledgeBase(index_dir=index_dir) # Manually build the collection with metadata kb.add_entries( @@ -239,7 +220,6 @@ def kb_with_chroma(self, tmp_path): {"tool_name": "fastp", "session_id": "s2"}, {"tool_name": "megahit", "session_id": "s2"}, ], - route=chroma_route, ) yield kb @@ -248,14 +228,20 @@ def kb_with_chroma(self, tmp_path): def test_search_without_where(self, kb_with_chroma): """Search without where returns results from all entries.""" results = kb_with_chroma.search( - "fastp quality", collection="mem", top_k=4, rerank=False, + "fastp quality", + collection="mem", + top_k=4, + rerank=False, ) assert len(results) > 0 def test_search_with_where_filters(self, kb_with_chroma): """Search with where restricts to matching metadata.""" results = kb_with_chroma.search( - "quality filtering", collection="mem", top_k=4, rerank=False, + "quality filtering", + collection="mem", + top_k=4, + rerank=False, where={"tool_name": "fastp"}, ) # All results should be fastp entries @@ -265,49 +251,27 @@ def test_search_with_where_filters(self, kb_with_chroma): def test_search_with_session_filter(self, kb_with_chroma): """Search with session_id filter returns only that session.""" results = kb_with_chroma.search( - "assembly", collection="mem", top_k=4, rerank=False, + "assembly", + collection="mem", + top_k=4, + rerank=False, where={"session_id": "s2"}, ) for r in results: assert r["chunk"]["metadata"]["session_id"] == "s2" - def test_search_where_on_faiss_ignored(self, tmp_path): - """where parameter is silently ignored on FAISS collections.""" - index_dir = tmp_path / "kb_faiss" - - with patch("dsagt.knowledge._make_embedder") as mock_make: - mock_embedder = MagicMock() - mock_embedder.embed = fake_embed - mock_make.return_value = mock_embedder - - kb = KnowledgeBase(index_dir=index_dir, default_index="faiss") - - # Use add_entries on a FAISS collection - kb.add_entries( - texts=["hello world", "goodbye world"], - collection="faiss_coll", - ) - - # where should not cause an error - results = kb.search( - "hello", collection="faiss_coll", top_k=2, rerank=False, - where={"some_field": "value"}, - ) - # Should still return results (where was ignored) - assert len(results) > 0 - kb.close() - # --------------------------------------------------------------------------- # KnowledgeBase.add_entries # --------------------------------------------------------------------------- + class TestAddEntries: @pytest.fixture def mock_kb(self, tmp_path): """KnowledgeBase with mocked embedder.""" - with patch("dsagt.knowledge._make_embedder") as mock_make: + with patch("dsagt.knowledge.Embedder.create") as mock_make: mock_embedder = MagicMock() mock_embedder.embed = fake_embed mock_make.return_value = mock_embedder @@ -341,24 +305,19 @@ def test_add_entries_empty_list(self, mock_kb): def test_add_entries_with_metadata(self, tmp_path): """add_entries stores metadata in chunks.jsonl.""" - chroma_route = CollectionRoute( - embedding_backend="api", vector_db="chroma", - ) - with patch("dsagt.knowledge._make_embedder") as mock_make: + with patch("dsagt.knowledge.Embedder.create") as mock_make: mock_embedder = MagicMock() mock_embedder.embed = fake_embed mock_make.return_value = mock_embedder kb = KnowledgeBase( index_dir=tmp_path / "kb", - routes={"mem": chroma_route}, ) kb.add_entries( texts=["ran fastp with Q20"], collection="mem", metadatas=[{"tool_name": "fastp", "session_id": "s1"}], - route=chroma_route, ) # Verify chunks.jsonl includes metadata @@ -378,7 +337,10 @@ def test_add_entries_searchable(self, mock_kb): collection="test_search", ) results = mock_kb.search( - "quick fox", collection="test_search", top_k=2, rerank=False, + "quick fox", + collection="test_search", + top_k=2, + rerank=False, ) assert len(results) > 0 @@ -406,20 +368,9 @@ def test_add_entries_return_embeddings_on(self, mock_kb): norms = np.linalg.norm(embeddings, axis=1) assert np.allclose(norms, 1.0, atol=1e-5) - def test_add_entries_return_embeddings_empty(self, mock_kb): - """Empty input with return_embeddings=True returns an empty array.""" - result = mock_kb.add_entries( - texts=[], collection="ret_empty", return_embeddings=True, - ) - assert "embeddings" in result - assert result["embeddings"].shape == (0,) - - def test_add_entries_with_route(self, tmp_path): - """add_entries with explicit ChromaDB route uses ChromaDB.""" - chroma_route = CollectionRoute( - embedding_backend="api", vector_db="chroma", - ) - with patch("dsagt.knowledge._make_embedder") as mock_make: + def test_add_entries_uses_chroma(self, tmp_path): + """add_entries writes a ChromaDB-backed collection (chroma_ids.json).""" + with patch("dsagt.knowledge.Embedder.create") as mock_make: mock_embedder = MagicMock() mock_embedder.embed = fake_embed mock_make.return_value = mock_embedder @@ -428,37 +379,11 @@ def test_add_entries_with_route(self, tmp_path): kb.add_entries( texts=["test entry"], collection="chroma_coll", - route=chroma_route, ) - # Verify it used ChromaDB (chroma_ids.json exists, not index.faiss) + # Verify it used ChromaDB (chroma_ids.json exists) coll_dir = tmp_path / "kb" / "chroma_coll" assert (coll_dir / "chroma_ids.json").exists() - assert not (coll_dir / "index.faiss").exists() - - kb.close() - - def test_add_entries_preserves_route(self, tmp_path): - """Route is persisted so subsequent searches use the right backend.""" - chroma_route = CollectionRoute( - embedding_backend="api", vector_db="chroma", - ) - with patch("dsagt.knowledge._make_embedder") as mock_make: - mock_embedder = MagicMock() - mock_embedder.embed = fake_embed - mock_make.return_value = mock_embedder - - kb = KnowledgeBase(index_dir=tmp_path / "kb") - kb.add_entries( - texts=["test entry"], - collection="routed", - route=chroma_route, - ) - - route_path = tmp_path / "kb" / "routed" / "route.json" - assert route_path.exists() - route_data = json.loads(route_path.read_text()) - assert route_data["vector_db"] == "chroma" kb.close() @@ -467,21 +392,18 @@ def test_add_entries_preserves_route(self, tmp_path): # End-to-end: add_entries + filtered search # --------------------------------------------------------------------------- + class TestAddEntriesFilteredSearch: """Integration test: add entries with metadata, then search with where.""" def test_filtered_search_returns_correct_subset(self, tmp_path): - chroma_route = CollectionRoute( - embedding_backend="api", vector_db="chroma", - ) - with patch("dsagt.knowledge._make_embedder") as mock_make: + with patch("dsagt.knowledge.Embedder.create") as mock_make: mock_embedder = MagicMock() mock_embedder.embed = fake_embed mock_make.return_value = mock_embedder kb = KnowledgeBase( index_dir=tmp_path / "kb", - routes={"executions": chroma_route}, ) kb.add_entries( @@ -498,13 +420,14 @@ def test_filtered_search_returns_correct_subset(self, tmp_path): {"tool_name": "fastp", "session_id": "s2", "return_code": 0}, {"tool_name": "megahit", "session_id": "s2", "return_code": 1}, ], - route=chroma_route, ) # Filter by tool_name fastp_results = kb.search( - "quality filtering", collection="executions", - top_k=4, rerank=False, + "quality filtering", + collection="executions", + top_k=4, + rerank=False, where={"tool_name": "fastp"}, ) for r in fastp_results: @@ -512,8 +435,10 @@ def test_filtered_search_returns_correct_subset(self, tmp_path): # Filter by session s1_results = kb.search( - "assembly", collection="executions", - top_k=4, rerank=False, + "assembly", + collection="executions", + top_k=4, + rerank=False, where={"session_id": "s1"}, ) for r in s1_results: @@ -521,8 +446,10 @@ def test_filtered_search_returns_correct_subset(self, tmp_path): # Compound filter failed = kb.search( - "megahit", collection="executions", - top_k=4, rerank=False, + "megahit", + collection="executions", + top_k=4, + rerank=False, where={"$and": [{"tool_name": "megahit"}, {"return_code": 1}]}, ) for r in failed: diff --git a/tests/test_config.py b/tests/test_config.py index 6670b70..95e794f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -23,7 +23,7 @@ static_agent_record, static_agent_files_present, ) -from dsagt.session import init_project, persist_agent_choice +from dsagt.session import init_project @pytest.fixture(autouse=True) @@ -36,10 +36,10 @@ def _use_tmp_registry(tmp_path): registry = {} def fake_load(): - # Auto-discover: any subdir of tmp_path with dsagt_config.yaml counts + # Auto-discover: any subdir of tmp_path with .dsagt/config.yaml counts discovered = dict(registry) for child in tmp_path.iterdir(): - if child.is_dir() and (child / "dsagt_config.yaml").exists(): + if child.is_dir() and (child / ".dsagt" / "config.yaml").exists(): discovered.setdefault(child.name, str(child)) return discovered @@ -50,12 +50,21 @@ def fake_save(reg): def fake_register(name, path): registry[name] = str(Path(path).resolve()) + # Isolate the shared KB to tmp_path and stub the asset build so init + # tests stay fast and offline (no embedding-model load, no git clone). + def _noop_ensure_assets(*_a, **_k): + return {"built": [], "skipped": []} + with patch("dsagt.session._load_registry", fake_load): with patch("dsagt.session._save_registry", fake_save): with patch("dsagt.session.register_project", fake_register): with patch("dsagt.session.DEFAULT_PROJECTS_BASE", tmp_path): - with patch("dsagt.session.DEFAULT_PROJECTS_BASE", tmp_path): - yield + with patch("dsagt.session.REGISTRY_DIR", tmp_path): + with patch( + "dsagt.commands.setup_core_kb.ensure_assets", + _noop_ensure_assets, + ): + yield # --------------------------------------------------------------------------- @@ -111,8 +120,8 @@ class TestLoadConfig: def _write_config(self, tmp_path, name, content: dict): pdir = tmp_path / name - pdir.mkdir(exist_ok=True) - (pdir / "dsagt_config.yaml").write_text( + (pdir / ".dsagt").mkdir(parents=True, exist_ok=True) + (pdir / ".dsagt" / "config.yaml").write_text( yaml.dump(content, default_flow_style=False) ) return name @@ -132,32 +141,15 @@ def test_loads_minimal_config(self, tmp_path): assert config["project"] == "myproject" assert config["agent"] == "goose" - # Ports are no longer defaulted in YAML — start_services picks them - # at runtime via socket.bind(("", 0)) and writes them to .runtime. - assert "proxy" not in config or "port" not in config.get("proxy", {}) + # Serverless: no mlflow block at all — the store is a sqlite path + # resolved from the project dir, nothing to pin in config. + assert "proxy" not in config + assert "mlflow" not in config # User-supplied keys win on the merge; DEFAULTS fills in missing # llm.* fields with ``${VAR}`` placeholders (resolved by # ``resolve_env_vars`` against the user's shell, or filtered by - # ``_real()`` if env unset). In BYOA the agent_env gate prevents - # env_overrides from acting on these — they only matter when - # --enable-proxy populates ``config["proxy"]["port"]``. + # ``_real()`` if env unset). assert config["llm"]["provider"] == "openai" # user value preserved - assert config["mlflow"]["backend"] == "sqlite" # non-llm defaults kept - - def test_overrides_defaults(self, tmp_path): - name = self._write_config( - tmp_path, - "myproject", - { - "project": "myproject", - "agent": "claude", - "llm": {"provider": "openai"}, - "proxy": {"port": 9000}, # explicit override stays honored - }, - ) - - config = load_config(name) - assert config["proxy"]["port"] == 9000 def test_missing_project_raises(self, tmp_path): name = self._write_config(tmp_path, "myproject", {"agent": "goose"}) @@ -165,18 +157,20 @@ def test_missing_project_raises(self, tmp_path): load_config(name) def test_skills_block_backfilled_for_old_config(self, tmp_path): - """A config written before the skills block still gets the default.""" + """A config with no skills block gets the default genesis source. + + ``populate_native`` is a code default (in ``AgentSetup.setup_skills``), + not a config key — so it isn't backfilled here.""" name = self._write_config( tmp_path, "myproject", - {"project": "myproject", "agent": "claude", "llm": {"provider": "openai"}}, + {"project": "myproject", "agent": "claude"}, ) config = load_config(name) sources = config["skills"]["sources"] - assert sources[0]["name"] == "scientific" - assert "K-Dense-AI" in sources[0]["url"] - assert config["skills"]["populate_native"] is True - assert config["skills"]["populate_catalog"] is True + assert sources[0]["name"] == "genesis" + assert "genesis-skills" in sources[0]["url"] + assert "populate_native" not in config["skills"] def test_missing_agent_raises(self, tmp_path): name = self._write_config(tmp_path, "myproject", {"project": "myproject"}) @@ -213,11 +207,11 @@ class TestSkillsDefaults: def test_defaults_has_skills(self): from dsagt.session import DEFAULTS - assert DEFAULTS["skills"]["sources"][0]["name"] == "scientific" + assert DEFAULTS["skills"]["sources"][0]["name"] == "genesis" def test_default_config_content_includes_skills(self): - body = yaml.safe_load(default_config_content("p", "claude", 5001)) - assert body["skills"]["sources"][0]["name"] == "scientific" + body = yaml.safe_load(default_config_content("p", "claude")) + assert body["skills"]["sources"][0]["name"] == "genesis" # --------------------------------------------------------------------------- @@ -242,7 +236,7 @@ def test_unregistered_project_raises(self): class TestDefaultConfigContent: def test_roundtrips_as_valid_yaml(self): - content = default_config_content("test", "goose", mlflow_port=5000) + content = default_config_content("test", "goose") parsed = yaml.safe_load(content) assert parsed["project"] == "test" assert parsed["agent"] == "goose" @@ -250,15 +244,133 @@ def test_roundtrips_as_valid_yaml(self): def test_no_user_facing_llm_block(self): """BYOA: project YAML is internal-only — no llm: block, no ${VAR} placeholders. User credentials live in their shell.""" - content = default_config_content("test", "claude", mlflow_port=5000) + content = default_config_content("test", "claude") parsed = yaml.safe_load(content) assert "llm" not in parsed assert "${" not in content - def test_pinned_mlflow_port_lands_in_yaml(self): - content = default_config_content("test", "claude", mlflow_port=12345) + def test_no_mlflow_port_pinned(self): + """Serverless store — nothing to pin. The config carries no + mlflow block at all.""" + content = default_config_content("test", "claude") parsed = yaml.safe_load(content) - assert parsed["mlflow"]["port"] == 12345 + assert "mlflow" not in parsed + + +# --------------------------------------------------------------------------- +# CLI: init choice resolution (_collect_settings) +# --------------------------------------------------------------------------- + + +class TestCollectSettings: + """``_collect_settings`` resolves the init choices into the config blocks. + + Two selection questions (KB collections, skill sources) + agent; the + bundled ``tools`` collection is always provisioned and not a choice. + """ + + def test_interactive_menus(self): + """Interactive path drives questionary select/checkbox; genesis is the + default-checked skill source, collections default to none, tools is + always in the provisioning set.""" + import types + import questionary + from unittest.mock import patch + from dsagt.commands import cli + + def fake_select(message, choices, default, **kw): + return _Ask(default) + + def fake_checkbox(message, choices, **kw): + return _Ask([c.value for c in choices if c.checked]) + + class _Ask: + def __init__(self, ret): + self.ret = ret + + def ask(self): + return self.ret + + args = types.SimpleNamespace(agent=None, include=None, exclude=None) + with ( + patch.object(questionary, "select", fake_select), + patch.object(questionary, "checkbox", fake_checkbox), + patch.object(cli, "_confirm", lambda *a, **k: False), # episodic off + ): + s = cli._collect_settings(args, interactive=True, existing={}, pdir=None) + + assert s["agent"] == "claude" + assert s["knowledge"] == {"collections": []} + assert [src["name"] for src in s["skills"]["sources"]] == ["genesis"] + assert s["assets"] == ["tools", "genesis"] # tools always provisioned + assert s["episodic"] is None # opt-in, off by default + + def test_interactive_episodic_enabled(self): + """Enabling episodic at the prompt yields a Tier-1 local-judge block + with the free-text domain tags merged in.""" + import types + import questionary + from unittest.mock import patch + from dsagt.commands import cli + + class _Ask: + def __init__(self, ret): + self.ret = ret + + def ask(self): + return self.ret + + args = types.SimpleNamespace(agent=None, include=None, exclude=None) + with ( + patch.object(questionary, "select", lambda *a, **k: _Ask("claude")), + patch.object(questionary, "checkbox", lambda *a, **k: _Ask([])), + patch.object(cli, "_confirm", lambda *a, **k: True), + patch.object(cli, "_prompt", lambda *a, **k: "assembly, variant_calling"), + ): + s = cli._collect_settings(args, interactive=True, existing={}, pdir=None) + + assert s["episodic"]["enabled"] is True + assert s["episodic"]["judge"]["backend"] == "local" + assert s["episodic"]["domain_tags"] == { + "assembly": "assembly", + "variant_calling": "variant_calling", + } + + def test_non_interactive_splits_assets(self): + """No-TTY path splits --include into collections vs skill sources; + tools is always provisioned. Episodic omitted → None.""" + import types + from dsagt.commands import cli + + args = types.SimpleNamespace( + agent="goose", include=["tools", "nemo_curator", "anthropic"], exclude=None + ) + s = cli._collect_settings(args, interactive=False, existing={}, pdir=None) + assert s["agent"] == "goose" + assert s["knowledge"]["collections"] == ["nemo_curator"] + assert [src["name"] for src in s["skills"]["sources"]] == ["anthropic"] + assert s["assets"] == ["tools", "nemo_curator", "anthropic"] + assert s["episodic"] is None + + def test_non_interactive_episodic_flag(self): + """--episodic + --domain-tags build the Tier-1 block on the no-TTY path.""" + import types + from dsagt.commands import cli + + args = types.SimpleNamespace( + agent="goose", + include=["tools"], + exclude=None, + episodic=True, + domain_tags="proteomics, qc_metrics", + ) + s = cli._collect_settings(args, interactive=False, existing={}, pdir=None) + assert s["episodic"]["enabled"] is True + assert s["episodic"]["judge"]["backend"] == "local" + assert s["episodic"]["domain_tags"] == { + "proteomics": "proteomics", + "qc_metrics": "qc_metrics", + } # --------------------------------------------------------------------------- @@ -269,18 +381,21 @@ def test_pinned_mlflow_port_lands_in_yaml(self): class TestInitProject: def test_creates_directory_structure(self): - pdir, _port = init_project("myproj", "goose") + pdir = init_project("myproj", "goose") assert pdir.exists() - assert (pdir / "dsagt_config.yaml").exists() + assert (pdir / ".dsagt" / "config.yaml").exists() assert (pdir / "trace_archive").is_dir() - assert (pdir / "mlflow").is_dir() assert (pdir / "skills").is_dir() assert (pdir / "kb_index").is_dir() assert (pdir / ".dsagt").is_dir() # `tools/` is intentionally NOT created by init_project — ToolRegistry # creates it on first server startup so bundled tools get copied in. assert not (pdir / "tools").exists() + # Serverless: no MLflow store is pre-created; ``mlflow.db`` is + # written lazily by the MLflow client on first span. + assert not (pdir / "mlflow.db").exists() + assert not (pdir / "mlflow").exists() def test_config_is_valid(self): init_project("myproj", "claude") @@ -288,30 +403,68 @@ def test_config_is_valid(self): assert config["project"] == "myproj" assert config["agent"] == "claude" - def test_returns_pdir_and_port(self): - """BYOA: init_project returns (pdir, mlflow_port).""" - pdir, port = init_project("myproj", "goose") - assert isinstance(port, int) and port > 0 - # Port is persisted in the YAML so MCP servers can read it. + def test_returns_pdir(self): + """Serverless: init_project returns just the project dir — no port.""" + pdir = init_project("myproj", "goose") + assert pdir.exists() config = load_config("myproj") - assert config["mlflow"]["port"] == port - - def test_explicit_mlflow_port_honored(self): - from dsagt.session import pick_free_port - - chosen = pick_free_port() - _pdir, port = init_project("myproj", "goose", mlflow_port=chosen) - assert port == chosen + assert "mlflow" not in config + + def test_exclude_all_creates_empty_kb_without_building(self): + """``--exclude all`` provisions a valid project with an empty KB and + never attempts an asset build.""" + from dsagt.commands import setup_core_kb + + with patch.object(setup_core_kb, "ensure_assets") as mock_ensure: + pdir = init_project("myproj", "goose", exclude=["all"]) + mock_ensure.assert_not_called() + kb_index = pdir / "kb_index" + assert kb_index.is_dir() + assert not any(kb_index.iterdir()) + + def test_default_init_provisions_default_asset_set(self): + """Default init builds exactly the default asset set into the shared + cache (here stubbed) — tools + genesis, nothing heavier.""" + from dsagt.commands import setup_core_kb + + with patch.object( + setup_core_kb, "ensure_assets", return_value={"built": [], "skipped": []} + ) as mock_ensure: + init_project("myproj", "goose") + mock_ensure.assert_called_once() + requested = mock_ensure.call_args.args[0] + assert requested == ["tools", "genesis"] - def test_duplicate_raises(self): + def test_reinit_is_idempotent_update(self): + """``dsagt init`` is re-runnable: a second init on the same project + updates settings in place rather than raising.""" init_project("myproj", "goose") - with pytest.raises(FileExistsError): - init_project("myproj", "goose") + init_project("myproj", "claude") # re-init, switch agent + config = load_config("myproj") + assert config["agent"] == "claude" def test_invalid_agent_raises(self): with pytest.raises(ValueError): init_project("myproj", "invalid-agent") + def test_episodic_block_round_trips_through_config(self): + """init_project writes the opted-in episodic block; load_config reads it + back. A project without it backfills ``enabled: False`` from DEFAULTS.""" + epi = { + "enabled": True, + "judge": {"backend": "local", "model": ""}, + "domain_tags": {"assembly": "assembly"}, + "outlier_sensitivity": 0.0, + } + init_project("withmem", "goose", exclude=["all"], episodic=epi) + cfg = load_config("withmem") + assert cfg["episodic"]["enabled"] is True + assert cfg["episodic"]["domain_tags"] == {"assembly": "assembly"} + + init_project("nomem", "goose", exclude=["all"]) + cfg2 = load_config("nomem") + assert cfg2["episodic"]["enabled"] is False # backfilled default + def test_static_record_written_eagerly(self, tmp_path): """BYOA flow writes static + dynamic records at init time so the user can edit instructions and inspect the MCP config artifact @@ -319,132 +472,61 @@ def test_static_record_written_eagerly(self, tmp_path): the CLI command, not init_project itself — but the agent dir layout exists post-init. """ - pdir, _ = init_project("myproj", "claude") + init_project("myproj", "claude") config = load_config("myproj") assert config["agent"] == "claude" # --------------------------------------------------------------------------- -# persist_agent_choice: first-start agent selection writes back to YAML +# Session state (.dsagt/state.yaml) — owned by the MCP server # --------------------------------------------------------------------------- -class TestPersistAgentChoice: - """``persist_agent_choice`` is called from ``_cmd_start`` when the - YAML doesn't already pin an agent — covers the case where the user - deferred ``--agent`` from init time and supplied it on first start. +class TestSessionState: + """The MCP server mints sessions into ``.dsagt/state.yaml`` (a monotonic + per-project counter) and ``dsagt-run`` reads the current tag from there. """ - def test_overwrites_existing_field(self, tmp_path): - init_project("myproj", "goose") - persist_agent_choice("myproj", "claude") - - config = load_config("myproj") - assert config["agent"] == "claude" - - def test_invalid_agent_raises(self, tmp_path): - init_project("myproj", "goose") - with pytest.raises(ValueError): - persist_agent_choice("myproj", "not-an-agent") - - -# --------------------------------------------------------------------------- -# pick_free_port: kernel-assigned via socket.bind(("", 0)) -# --------------------------------------------------------------------------- - - -class TestPickFreePort: - - def test_returns_a_usable_port(self): - """The kernel hands back a positive port number we can bind.""" - from dsagt.session import pick_free_port - import socket as _socket - - port = pick_free_port() - assert isinstance(port, int) - assert 1024 <= port <= 65535 - # And we can actually bind it (no leak, no half-stuck socket). - with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as s: - s.bind(("", port)) - - def test_consecutive_calls_pick_different_ports(self): - """No memoization, no preferred-port bias — consecutive calls just - reflect whatever the kernel hands out (almost always different on - a quiet box, very rarely the same; this test tolerates that).""" - from dsagt.session import pick_free_port - - ports = {pick_free_port() for _ in range(10)} - # Not strictly guaranteed all 10 differ, but >1 distinct is expected. - assert len(ports) > 1 - - -# --------------------------------------------------------------------------- -# reap_runtime: SIGTERM PIDs in /.runtime, grace, SIGKILL stragglers -# --------------------------------------------------------------------------- - - -class TestReapRuntime: - - def test_no_runtime_file_returns_empty(self, tmp_path): - from dsagt.session import reap_runtime - - assert reap_runtime(tmp_path / ".runtime") == [] - - def test_skips_pids_whose_cmdline_doesnt_match(self, tmp_path, monkeypatch): - """PID-recycling guard: cmdline doesn't name our service → leave alone.""" - from dsagt import session - - runtime = tmp_path / ".runtime" - runtime.write_text( - json.dumps( - { - "pids": {"mlflow": 99999, "proxy": 99998}, - "ports": {"mlflow": 12345, "proxy": 12346}, - } - ) - ) - # Pretend both PIDs are recycled — cmdline doesn't contain our name. - monkeypatch.setattr(session, "_process_command", lambda pid: "/bin/zsh") - killed = session.reap_runtime(runtime) - assert killed == [] - # File still gets cleaned up so a stale .runtime doesn't linger. - assert not runtime.exists() - - def test_signals_pids_whose_cmdline_matches(self, tmp_path, monkeypatch): - """When the cmdline still names our service, SIGTERM is sent.""" - from dsagt import session - - runtime = tmp_path / ".runtime" - runtime.write_text( - json.dumps( - { - "pids": {"mlflow": 11111, "proxy": 22222}, - "ports": {"mlflow": 12345, "proxy": 12346}, - } - ) - ) - monkeypatch.setattr( - session, - "_process_command", - lambda pid: f"python -m {'mlflow' if pid == 11111 else 'dsagt.commands.proxy_server'}", + def test_append_session_increments(self, tmp_path): + from dsagt.session import append_session, current_session + + pdir = tmp_path / "proj" + pdir.mkdir() + e1 = append_session(pdir) + e2 = append_session(pdir) + assert e1["id"] == 1 + assert e2["id"] == 2 + assert e1["started_at"].endswith("Z") + assert current_session(pdir)["id"] == 2 + + def test_session_tag_shape(self): + from dsagt.session import session_tag + + assert session_tag("myproj", 3) == "myproj-3" + + def test_current_session_tag_from_state(self, tmp_path): + from dsagt.session import ( + append_session, + current_session_tag, + write_config_file, + build_config, ) - signals_sent: list[tuple[int, int]] = [] - def fake_killpg(pgid, sig): - signals_sent.append((pgid, sig)) + pdir = tmp_path / "proj" + pdir.mkdir() + write_config_file(pdir, build_config("proj", "claude")) + assert current_session_tag(pdir, "proj") is None # no session yet + append_session(pdir) + assert current_session_tag(pdir, "proj") == "proj-1" - monkeypatch.setattr(session.os, "killpg", fake_killpg) - monkeypatch.setattr(session.os, "getpgid", lambda pid: pid) - # Make grace-wait short and the process appear to exit immediately. - monkeypatch.setattr(session, "_STOP_GRACE_SECONDS", 0) + def test_update_cursor_roundtrip(self, tmp_path): + from dsagt.session import read_state, update_cursor - killed = session.reap_runtime(runtime) - assert len(killed) == 2 - # Both PIDs got SIGTERM. - import signal as _signal - - sigterm_pgids = {pgid for pgid, sig in signals_sent if sig == _signal.SIGTERM} - assert sigterm_pgids == {11111, 22222} + pdir = tmp_path / "proj" + pdir.mkdir() + update_cursor(pdir, tool_use_indexed_through="2026-01-01T00:00:00Z") + cur = read_state(pdir)["memory_cursor"] + assert cur["tool_use_indexed_through"] == "2026-01-01T00:00:00Z" # --------------------------------------------------------------------------- @@ -465,13 +547,8 @@ def _init_and_load(self, agent): return load_config("testproj") def _write_both(self, config, working_dir): - """Run static then dynamic — what dsagt start does, minus services. - - ``start_services`` populates ``config["mlflow"]["port"]`` in - production; we stub it here so ``agent_env`` (which reads it to - build the OTLP endpoint) doesn't KeyError. - """ - config.setdefault("mlflow", {})["port"] = 5001 + """Run static then dynamic — what dsagt start does. Serverless: + no port to populate; the store resolves from the project dir.""" static_agent_record(config, config["agent"], working_dir) env = agent_env(config) dynamic_agent_record(config, env, working_dir) @@ -506,23 +583,6 @@ def test_goose_writes_goose_yaml(self, tmp_path): assert goose["extensions"]["dsagt"]["cmd"] == "uv run dsagt-server" assert (working_dir / ".goosehints").exists() - def test_roo_writes_static_and_dynamic(self, tmp_path): - config = self._init_and_load("roo") - working_dir = tmp_path / "workdir" - working_dir.mkdir() - - self._write_both(config, working_dir) - - assert (working_dir / ".roo").is_dir() - assert (working_dir / ".roomodes").exists() - assert (working_dir / ".roo" / "mcp.json").exists() - # BYOA: project routing (project name, project_dir, mlflow port) - # comes from dsagt_config.yaml via cwd-walk; the MCP env block - # only carries EMBEDDING_* settings. - mcp = json.loads((working_dir / ".roo" / "mcp.json").read_text()) - assert set(mcp["mcpServers"]) == {"dsagt"} - assert "EMBEDDING_BACKEND" in mcp["mcpServers"]["dsagt"]["env"] - def test_cline_writes_static_only_in_split_test(self, tmp_path): # Cline's dynamic writer shells out to `cline auth` and `cline mcp # add`, which would fail without cline installed. Test only the @@ -582,11 +642,11 @@ def test_static_files_present_check(self, tmp_path): assert static_agent_files_present("codex", working_dir) def test_codex_config_toml_shape(self, tmp_path): - """``_render_codex_config`` emits ``[mcp_servers.*]`` sections - plus an ``[otel]`` block (opting codex's partial-OTel emission - into MLflow via the OTLP exporter). No top-level keys — those - come from the user's ``~/.codex/config.toml`` which - ``write_dynamic`` copies as a base. + """``_render_codex_config`` emits only ``[mcp_servers.*]`` sections. + No ``[otel]`` block — DSAGT no longer forces codex's native + telemetry (nor the ``log_user_prompt`` privacy override). No + top-level keys — those come from the user's ``~/.codex/config.toml`` + which ``write_dynamic`` copies as a base. """ from dsagt.agents import _render_codex_config @@ -599,10 +659,9 @@ def test_codex_config_toml_shape(self, tmp_path): assert "[mcp_servers.dsagt]" in toml assert "[mcp_servers.dsagt.env]" in toml assert 'MLFLOW_TRACKING_URI = "http://localhost:5001"' in toml - # OTel opt-in: enables OTLP-HTTP export + un-redacts user prompt. - assert "[otel]" in toml - assert 'trace_exporter = "otlp-http"' in toml - assert "log_user_prompt = true" in toml + # No forced telemetry / privacy override. + assert "[otel]" not in toml + assert "log_user_prompt" not in toml # No top-level approval/sandbox keys — those come from user's # config.toml or the codex exec CLI flag. assert "approval_policy" not in toml @@ -715,11 +774,12 @@ def test_mcp_servers_dict_shape(self): == "http://localhost:5001" ) - def test_mcp_config_omits_redundant_dsagt_env(self, tmp_path): - """Single source of truth: project routing (project, project_dir, - mlflow port) lives in dsagt_config.yaml and is read via cwd-walk - by every dsagt service. The MCP env block must NOT duplicate - those values — that's the contract being asserted here.""" + def test_mcp_config_carries_routing_env(self, tmp_path): + """Benign routing in the MCP env block: agents that don't inherit + the parent's shell env into their MCP children (codex / cline + — and claude's block is robust against shells that don't + export it) need project name + dir and the serverless + ``MLFLOW_TRACKING_URI`` baked in. No credentials, no OTel.""" config = self._init_and_load("claude") working_dir = tmp_path / "workdir" working_dir.mkdir() @@ -728,9 +788,12 @@ def test_mcp_config_omits_redundant_dsagt_env(self, tmp_path): mcp = json.loads((working_dir / ".mcp.json").read_text()) env = mcp["mcpServers"]["dsagt"].get("env", {}) - assert "DSAGT_PROJECT" not in env - assert "DSAGT_PROJECT_DIR" not in env - assert "MLFLOW_TRACKING_URI" not in env + assert env["DSAGT_PROJECT"] == config["project"] + assert env["DSAGT_PROJECT_DIR"] == config["project_dir"] + assert env["MLFLOW_TRACKING_URI"].startswith("sqlite:///") + # No credentials or OTel routing leak into the MCP config. + assert "ANTHROPIC_API_KEY" not in env + assert "OTEL_EXPORTER_OTLP_ENDPOINT" not in env # --------------------------------------------------------------------------- @@ -739,7 +802,7 @@ def test_mcp_config_omits_redundant_dsagt_env(self, tmp_path): class TestResolveRecordsDirProjectAware: - """``_resolve_records_dir`` reads the project's ``dsagt_config.yaml`` + """``_resolve_records_dir`` reads the project's ``.dsagt/config.yaml`` from cwd as the single source of truth — no env-var chain.""" def test_explicit_overrides_cwd(self, tmp_path): @@ -749,12 +812,13 @@ def test_explicit_overrides_cwd(self, tmp_path): assert result == Path("/custom") def test_cwd_with_config(self, tmp_path, monkeypatch): - """When cwd contains ``dsagt_config.yaml``, records dir is + """When cwd contains ``.dsagt/config.yaml``, records dir is ``/trace_archive``. Even if env vars are set to point elsewhere, the config-in-cwd rule wins (env is ignored).""" from dsagt.provenance import _resolve_records_dir - (tmp_path / "dsagt_config.yaml").write_text("project: t\n") + (tmp_path / ".dsagt").mkdir() + (tmp_path / ".dsagt" / "config.yaml").write_text("project: t\n") monkeypatch.chdir(tmp_path) # Stale env vars must not be consulted. monkeypatch.setenv("DSAGT_PROJECT_DIR", "/stale/proj/dir") @@ -787,411 +851,54 @@ def test_dsagt_vars_set(self): assert env["DSAGT_AGENT"] == "claude" assert env["DSAGT_PROJECT_DIR"] == "/proj" - def test_claude_byoa_skips_otel_routing(self): - """Claude in BYOA mode (no proxy) uses ``mlflow autolog claude`` - for agent-side traces — its Stop hook produces richer - transcript-based traces than native OTel. agent_env should NOT - export OTEL_EXPORTER_OTLP_* vars for Claude in BYOA, to avoid - duplicate (and inferior) trace shapes. + def test_no_otel_routing_for_any_agent(self, monkeypatch): + """DSAGT forces no native OTel emission — agent traces are + recovered post-hoc from the on-disk transcript. ``agent_env`` + sets ``MLFLOW_TRACKING_URI`` (for MCP-server / MLflow-client + logging) but never the OTLP routing env, for any agent. """ from dsagt.agents import agent_env - env = agent_env(self._make_config("claude")) - assert env["MLFLOW_TRACKING_URI"] == "http://localhost:5001" - assert "OTEL_EXPORTER_OTLP_ENDPOINT" not in env - assert "OTEL_EXPORTER_OTLP_HEADERS" not in env - assert "OTEL_RESOURCE_ATTRIBUTES" not in env + for var in ( + "MLFLOW_TRACKING_URI", + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_HEADERS", + "OTEL_RESOURCE_ATTRIBUTES", + ): + monkeypatch.delenv(var, raising=False) - def test_non_claude_byoa_keeps_otel_routing(self): - """Goose has no ``mlflow autolog goose`` analog — it still gets - OTel routing so its native OTel emission lands in MLflow. + for agent in ("claude", "goose", "codex", "cline", "opencode"): + env = agent_env(self._make_config(agent)) + # Serverless sqlite store derived from the project dir. + assert env["MLFLOW_TRACKING_URI"] == "sqlite:////proj/mlflow.db" + assert "OTEL_EXPORTER_OTLP_ENDPOINT" not in env + assert "OTEL_EXPORTER_OTLP_HEADERS" not in env + assert "OTEL_RESOURCE_ATTRIBUTES" not in env + + def test_no_telemetry_flags_for_claude(self, monkeypatch): + """The forced Claude telemetry/privacy flags are gone — DSAGT no + longer flips ``CLAUDE_CODE_ENABLE_TELEMETRY`` / ``OTEL_LOG_*`` + (the latter defeated Anthropic's off-by-default redaction). + + Cleared from the inherited shell env first so we test that DSAGT + doesn't *add* them — not whatever the test runner's own shell set. """ from dsagt.agents import agent_env - env = agent_env(self._make_config("goose")) - assert env["MLFLOW_TRACKING_URI"] == "http://localhost:5001" - assert env["OTEL_EXPORTER_OTLP_ENDPOINT"] == "http://localhost:5001/v1/traces" - assert "service.name=goose" in env["OTEL_RESOURCE_ATTRIBUTES"] - - def test_claude_telemetry_verbosity_flags(self): - """Claude Code needs OTEL_LOG_TOOL_DETAILS + OTEL_LOG_USER_PROMPTS - to emit unredacted tool_use payloads + user prompts memory - extraction depends on. OTEL_LOG_RAW_API_BODIES is intentionally - NOT in the static env block — its value is a per-project file - path rendered dynamically by ``_cmd_mlflow`` (see claude.py for - why ``=1`` mode would lose bodies to MLflow's missing /v1/logs - endpoint).""" - from dsagt.agents import agent_env - - env = agent_env(self._make_config("claude")) - assert env["CLAUDE_CODE_ENABLE_TELEMETRY"] == "1" - assert env["OTEL_LOG_TOOL_DETAILS"] == "1" - assert env["OTEL_LOG_USER_PROMPTS"] == "1" - assert "OTEL_LOG_RAW_API_BODIES" not in env - - -@pytest.mark.skip( - reason=( - "Old-code-shape env_overrides: assertions describe the pre-Phase-1 " - "design where env_overrides translated llm.* into ANTHROPIC_*/OPENAI_* " - "credential env vars across the board. Phase 2's env_overrides is " - "narrower — it pins the agent's MODEL env var only; provider creds " - "and base URLs are set by proxy_env_overrides (sentinel + proxy URL) " - "or live in agent config files (cline auth state, codex config.toml, " - "opencode.json). See ``TestPhase2EnvOverrides`` below for the new " - "contract. Keeping these tests around for the LoC reference; " - "delete when Phase 2 is stable." - ) -) -class TestProviderEnvInjection: - """``llm.{provider, model, api_key, base_url}`` from dsagt_config must - flow into the agent's expected env-var names — otherwise the agent - falls back to its own user-config and the project YAML's settings - don't actually drive the agent. This was a real bug for goose.""" - - def _config_with_llm(self, **llm) -> dict: - return { - "project": "test", - "agent": "goose", - "project_dir": "/proj", - "mlflow": {"port": 5001}, - "llm": llm, - "embedding": {}, - } - - def test_openai_provider_sets_openai_env(self): - from dsagt.agents import agent_env - - env = agent_env( - self._config_with_llm( - provider="openai", - model="gpt-4o", - api_key="sk-real", - base_url="https://gateway.example.com", - ) - ) - assert env["OPENAI_API_KEY"] == "sk-real" - assert env["OPENAI_BASE_URL"] == "https://gateway.example.com" - assert env["GOOSE_PROVIDER"] == "openai" - assert env["GOOSE_MODEL"] == "gpt-4o" - # Goose's Rust openai client reads OPENAI_HOST, not OPENAI_BASE_URL. - # Without this, goose silently hits api.openai.com regardless of - # the project's gateway. - assert env["OPENAI_HOST"] == "https://gateway.example.com" - - def test_goose_anthropic_sets_anthropic_host(self): - """Same HOST-vs-BASE_URL issue for goose's anthropic provider.""" - from dsagt.agents import agent_env - - env = agent_env( - self._config_with_llm( - provider="anthropic", - model="claude-sonnet", - api_key="anthropic-key", - base_url="https://api.anthropic.com", - ) - ) - assert env["ANTHROPIC_HOST"] == "https://api.anthropic.com" - - @pytest.mark.skip(reason="proxy mode deferred to Phase 2") - def test_goose_proxy_sets_host_at_proxy(self): - """Proxy mode: OPENAI_HOST / ANTHROPIC_HOST must point at the proxy - too, not just the standard BASE_URL slots.""" - from dsagt.agents import agent_env - - config = self._config_with_llm( - provider="openai", - api_key="sk-real", - base_url="https://upstream.example.com", - ) - config["proxy"] = {"port": 9999} - env = agent_env(config) - assert env["OPENAI_HOST"] == "http://localhost:9999" - assert env["ANTHROPIC_HOST"] == "http://localhost:9999" - - def test_openai_like_provider_treated_as_openai(self): - """openai_like (lab gateways speaking OpenAI wire protocol) maps - to OPENAI_* env vars too — that's what every OpenAI-compat client - reads.""" - from dsagt.agents import agent_env - - env = agent_env( - self._config_with_llm( - provider="openai_like", - api_key="sk-lab", - base_url="https://lab.example.com", - ) - ) - assert env["OPENAI_API_KEY"] == "sk-lab" - assert env["OPENAI_BASE_URL"] == "https://lab.example.com" - - def test_anthropic_provider_sets_anthropic_env(self): - from dsagt.agents import agent_env - - env = agent_env( - self._config_with_llm( - provider="anthropic", - model="claude-sonnet-4-5", - api_key="anthropic-key", - base_url="https://api.anthropic.com", - ) - ) - assert env["ANTHROPIC_API_KEY"] == "anthropic-key" - assert env["ANTHROPIC_BASE_URL"] == "https://api.anthropic.com" - # ANTHROPIC_MODEL pins the model so claude code doesn't fall back - # to its built-in default (which won't exist on lab gateways). - assert env["ANTHROPIC_MODEL"] == "claude-sonnet-4-5" - - def test_cline_gets_provider_env(self): - """Cline's CLI reads OPENAI_*/ANTHROPIC_* — must flow from llm.*.""" - from dsagt.agents import agent_env - - cfg = self._config_with_llm( - provider="openai", - api_key="sk-cline", - base_url="https://gateway.example.com", - ) - cfg["agent"] = "cline" - env = agent_env(cfg) - assert env["OPENAI_API_KEY"] == "sk-cline" - assert env["OPENAI_BASE_URL"] == "https://gateway.example.com" - - def test_roo_gets_provider_env(self): - """Roo's CLI reads ANTHROPIC_* — must flow from llm.*.""" - from dsagt.agents import agent_env - - cfg = self._config_with_llm( - provider="anthropic", - model="claude-haiku-4-5", - api_key="anthropic-roo-key", - base_url="https://api.anthropic.com", - ) - cfg["agent"] = "roo" - env = agent_env(cfg) - assert env["ANTHROPIC_API_KEY"] == "anthropic-roo-key" - assert env["ANTHROPIC_BASE_URL"] == "https://api.anthropic.com" - assert env["ANTHROPIC_MODEL"] == "claude-haiku-4-5" - - def test_unresolved_placeholder_skipped(self): - """``${VAR}`` (unresolved interpolation) must NOT be propagated — - we only inject real values.""" - from dsagt.agents import agent_env - - env = agent_env( - self._config_with_llm( - provider="openai", - api_key="${LLM_API_KEY}", - base_url="${LLM_BASE_URL}", - ) - ) - # Provider still set, but bogus key/url not injected. - assert env["GOOSE_PROVIDER"] == "openai" - assert env.get("OPENAI_API_KEY") != "${LLM_API_KEY}" - assert env.get("OPENAI_BASE_URL") != "${LLM_BASE_URL}" - - def test_blank_values_skipped(self): - from dsagt.agents import agent_env - - env = agent_env( - self._config_with_llm( - provider="openai", - api_key="", - base_url=" ", - ) + flags = ( + "CLAUDE_CODE_ENABLE_TELEMETRY", + "CLAUDE_CODE_ENHANCED_TELEMETRY_BETA", + "OTEL_LOG_TOOL_DETAILS", + "OTEL_LOG_USER_PROMPTS", + "OTEL_TRACES_EXPORTER", + "OTEL_LOG_RAW_API_BODIES", ) - # Blank values shouldn't override the user's shell-level env vars - # (which the os.environ copy in agent_env preserves). - assert env.get("OPENAI_API_KEY") != "" - assert env.get("OPENAI_BASE_URL") != " " - - def test_unknown_provider_still_sets_goose_vars(self): - """Goose can dispatch to unknown providers via its own config - fallback; we still set GOOSE_PROVIDER / GOOSE_MODEL so the agent - knows which provider to look up.""" - from dsagt.agents import agent_env - - env = agent_env( - self._config_with_llm( - provider="bedrock", - model="anthropic.claude-3-sonnet", - ) - ) - assert env["GOOSE_PROVIDER"] == "bedrock" - assert env["GOOSE_MODEL"] == "anthropic.claude-3-sonnet" - # No OPENAI_* / ANTHROPIC_* injection for unknown providers — user - # supplies those via their shell env (e.g. AWS_ACCESS_KEY_ID). - assert "OPENAI_API_KEY" not in env or env["OPENAI_API_KEY"] != "" - - @pytest.mark.skip(reason="proxy mode deferred to Phase 2") - def test_proxy_overrides_provider_injection(self): - """When --enable-proxy is set, the proxy block runs AFTER provider - injection and overrides OPENAI_BASE_URL / OPENAI_API_KEY with the - proxy URL + sentinel. Provider injection must not stomp the proxy.""" - from dsagt.agents import agent_env - - config = self._config_with_llm( - provider="openai", - api_key="sk-real", - base_url="https://upstream.example.com", - ) - config["proxy"] = {"port": 9999} - env = agent_env(config) - assert env["OPENAI_BASE_URL"] == "http://localhost:9999" - assert env["OPENAI_API_KEY"].startswith("dsagt-proxy-forwarded") - - def test_claude_does_not_get_openai_env(self): - """Protocol isolation: claude is anthropic-native. When the user - configures provider=openai, claude's env_overrides must NOT - propagate OPENAI_* — claude's runtime ignores them and the cross- - protocol mismatch is what the proxy is for. Setting them anyway - muddies the contract.""" - from dsagt.agents import agent_env - - cfg = self._config_with_llm( - provider="openai", - api_key="sk-real", - base_url="https://gateway.example.com", - ) - cfg["agent"] = "claude" - env = agent_env(cfg) - # Claude is openai-blind; project openai creds shouldn't show up - # in claude's process env (the user's shell may set them, in - # which case os.environ wins — that's a user-controlled choice). - # We assert ours specifically isn't injected. - assert env.get("OPENAI_BASE_URL") != "https://gateway.example.com" - - def test_codex_does_not_get_anthropic_env(self): - """Protocol isolation: codex is openai-native. Anthropic upstream - requires --enable-proxy for translation.""" - from dsagt.agents import agent_env - - cfg = self._config_with_llm( - provider="anthropic", - model="claude-sonnet", - api_key="anthropic-key", - base_url="https://api.anthropic.com", - ) - cfg["agent"] = "codex" - env = agent_env(cfg) - assert env.get("ANTHROPIC_BASE_URL") != "https://api.anthropic.com" - - -class TestPhase2EnvOverrides: - """Phase 2 ``env_overrides`` is narrower than old code: it pins the - agent's MODEL env var only. Provider creds / base URLs are set by - ``proxy_env_overrides`` (sentinel + proxy URL) or live in agent - config files (cline auth state, codex config.toml, opencode.json). - All gated by ``config["proxy"]["port"]`` — BYOA mode doesn't fire it. - """ - - def _proxy_config(self, agent: str, model: str = "test-model") -> dict: - return { - "project": "test", - "agent": agent, - "project_dir": "/proj", - "mlflow": {"port": 5001}, - "llm": { - "provider": "openai", - "model": model, - "base_url": "https://up", - "api_key": "sk-up", - }, - "embedding": {}, - "proxy": {"port": 9999}, - } - - def test_claude_pins_anthropic_model_in_proxy_mode(self): - from dsagt.agents import agent_env - - env = agent_env(self._proxy_config("claude", model="claude-test")) - assert env["ANTHROPIC_MODEL"] == "claude-test" - - def test_goose_pins_provider_and_model_in_proxy_mode(self): - from dsagt.agents import agent_env - - env = agent_env(self._proxy_config("goose", model="my-model")) - assert env["GOOSE_PROVIDER"] == "openai" - assert env["GOOSE_MODEL"] == "my-model" + for flag in flags: + monkeypatch.delenv(flag, raising=False) - def test_roo_pins_anthropic_model_in_proxy_mode(self): - from dsagt.agents import agent_env - - env = agent_env(self._proxy_config("roo", model="my-roo-model")) - assert env["ANTHROPIC_MODEL"] == "my-roo-model" - - def test_byoa_mode_does_not_fire_env_overrides(self): - """Without proxy_port in config, env_overrides is not called — - the user's shell ANTHROPIC_MODEL / GOOSE_MODEL etc. are not - overwritten by config["llm"] values. This is the gate that - prevents the Phase-1 GOOSE_MODEL bug from coming back. - """ - from dsagt.agents import agent_env - - cfg = self._proxy_config("goose", model="config-model") - cfg.pop("proxy") # BYOA: no proxy block - env = agent_env(cfg) - # GOOSE_MODEL only set if user's shell has it; we don't override. - assert env.get("GOOSE_MODEL") != "config-model" - - def test_unresolved_placeholder_filtered(self): - """A ${VAR} that didn't resolve at load_config time must not - leak into env vars (would be an obvious bug surface).""" - from dsagt.agents import agent_env - - cfg = self._proxy_config("goose", model="${LLM_MODEL}") - env = agent_env(cfg) - assert "GOOSE_MODEL" not in env or env["GOOSE_MODEL"] != "${LLM_MODEL}" - - -class TestProxyEnvOverrides: - """``--enable-proxy`` plants the same proxy-routing env vars on - every agent. AgentSetup.proxy_env_overrides centralizes the - contract; agents inherit the default unless they have a non- - standard env-var convention (goose's OPENAI_HOST/ANTHROPIC_HOST).""" - - def _make_config(self, agent: str) -> dict: - return { - "project": "test", - "agent": agent, - "project_dir": "/proj", - "mlflow": {"port": 5001}, - "llm": {"provider": "openai", "api_key": "sk-up", "base_url": "https://up"}, - "embedding": {}, - "proxy": {"port": 9999}, - } - - def test_default_sets_proxy_url_for_both_protocols(self): - from dsagt.agents.base import AgentSetup - - # Spin up any concrete subclass to call the inherited default. - from dsagt.agents.goose import GooseSetup - - env = GooseSetup().proxy_env_overrides(9999) - assert env["ANTHROPIC_BASE_URL"] == "http://localhost:9999" - assert env["OPENAI_BASE_URL"] == "http://localhost:9999" - assert env["EMBEDDING_BASE_URL"] == "http://localhost:9999" - - def test_default_plants_sentinel_keys(self): - from dsagt.agents.goose import GooseSetup - - env = GooseSetup().proxy_env_overrides(9999) - assert env["ANTHROPIC_API_KEY"].startswith("dsagt-proxy-forwarded") - assert env["OPENAI_API_KEY"].startswith("dsagt-proxy-forwarded") - assert env["EMBEDDING_API_KEY"].startswith("dsagt-proxy-forwarded") - - def test_proxy_applied_uniformly_across_agents(self): - """Every agent gets identical proxy env from the inherited - default — that's the whole point of putting it on the base class.""" - from dsagt.agents import agent_env - - envs = { - a: agent_env(self._make_config(a)) - for a in ("claude", "goose", "cline", "roo", "codex") - } - for agent, env in envs.items(): - assert env["ANTHROPIC_BASE_URL"] == "http://localhost:9999", agent - assert env["OPENAI_BASE_URL"] == "http://localhost:9999", agent - assert env["OPENAI_API_KEY"].startswith("dsagt-proxy-forwarded"), agent + env = agent_env(self._make_config("claude")) + for flag in flags: + assert flag not in env class TestPreconfiguredCredsWarning: @@ -1242,19 +949,6 @@ def test_no_warning_when_project_supplies_creds(self, caplog): msgs = " ".join(r.getMessage() for r in caplog.records) assert "preconfigured env vars" not in msgs - def test_no_warning_when_proxy_enabled(self, caplog): - """Proxy mode plants its own creds; transparency warning is moot.""" - from dsagt.agents import agent_env - - cfg = self._config(agent="claude") - cfg["proxy"] = {"port": 9999} - - with caplog.at_level("WARNING", logger="dsagt.agents"): - agent_env(cfg) - - msgs = " ".join(r.getMessage() for r in caplog.records) - assert "preconfigured env vars" not in msgs - def test_warns_with_no_shell_either_lists_expected_vars(self, caplog, monkeypatch): """When neither project nor shell has creds, surface the var names the agent's runtime expects so the user can fix the gap.""" @@ -1293,11 +987,6 @@ def test_goose(self): "uv run dsagt-server", ] - def test_roo(self): - from dsagt.agents import agent_command - - assert agent_command({"agent": "roo"}) == ["roo"] - def test_cline(self): from dsagt.agents import agent_command @@ -1316,12 +1005,32 @@ def test_codex(self): class TestConfigFlow: - def test_default_config_has_embedding_block(self): - """default_config_content() includes embedding (KB needs it).""" - content = default_config_content("test", "claude", mlflow_port=5000) + def test_default_config_mirrors_init_choices(self): + """The written config holds only the init choices: project, agent, + knowledge.collections, skills.sources. The bundled ``tools`` collection + is always provisioned (not a choice); embedding / chunk_size / rerank + are code defaults backfilled on read, never written.""" + content = default_config_content("test", "claude") parsed = yaml.safe_load(content) - assert "embedding" in parsed - assert "backend" in parsed["embedding"] + assert set(parsed) == {"project", "agent", "knowledge", "skills"} + assert parsed["knowledge"] == {"collections": []} + assert parsed["skills"]["sources"][0]["name"] == "genesis" + assert "embedding" not in parsed + assert "episodic" not in parsed # opt-in, omitted unless enabled + + def test_episodic_written_only_when_enabled(self): + """An opted-in episodic block is written verbatim; absent otherwise + (and ``load_config`` backfills ``enabled: false`` for the absent case).""" + epi = { + "enabled": True, + "judge": {"backend": "local", "model": ""}, + "domain_tags": {"assembly": "assembly"}, + "outlier_sensitivity": 0.0, + } + parsed = yaml.safe_load(default_config_content("t", "claude", episodic=epi)) + assert parsed["episodic"] == epi + # Omitted when None. + assert "episodic" not in yaml.safe_load(default_config_content("t", "claude")) def test_mcp_env_block_carries_embedding_routing(self): """_mcp_env_block plumbs embedding routing (model + base_url) @@ -1347,23 +1056,23 @@ def test_mcp_env_block_carries_embedding_routing(self): # Credentials never land in artifacts; user sets in shell. assert "EMBEDDING_API_KEY" not in env - def test_mcp_env_block_omits_project_routing(self): - """Single source of truth: MLflow URI / project name / project_dir - come from dsagt_config.yaml via cwd-walk, not from a duplicated - env block. The MCP env block carries only embedding-backend - settings (and EMBEDDING_API_KEY from the user's shell).""" + def test_mcp_env_block_carries_project_routing(self, monkeypatch): + """Benign routing: the MCP env block carries project name + dir and + the serverless ``MLFLOW_TRACKING_URI`` so MCP children of agents + that don't inherit the parent shell env still log to the right + store. No credentials, no OTel.""" from dsagt.agents import _mcp_env_block + monkeypatch.delenv("MLFLOW_TRACKING_URI", raising=False) config = { "project": "test", "project_dir": "/p", - "mlflow": {"port": 12345}, "embedding": {"backend": "local"}, } env = _mcp_env_block(config) - assert "MLFLOW_TRACKING_URI" not in env - assert "DSAGT_PROJECT" not in env - assert "DSAGT_PROJECT_DIR" not in env + assert env["DSAGT_PROJECT"] == "test" + assert env["DSAGT_PROJECT_DIR"] == "/p" + assert env["MLFLOW_TRACKING_URI"] == "sqlite:////p/mlflow.db" assert env["EMBEDDING_BACKEND"] == "local" def test_mcp_env_block_omits_empty_embedding_keys(self): @@ -1384,28 +1093,30 @@ def test_mcp_env_block_omits_empty_embedding_keys(self): def test_mcp_server_args_are_just_command(self): """MCP server args are just ["run", "dsagt-server"] — one merged - server. All configuration flows through dsagt_config.yaml (cwd-walk). + server. All configuration flows through .dsagt/config.yaml (cwd-walk). """ from dsagt.agents import _mcp_server_args assert _mcp_server_args() == ["run", "dsagt-server"] - def test_mcp_env_block_carries_only_embedding_settings(self): + def test_mcp_env_block_carries_no_session_id(self): + """The MCP server owns the session lifecycle now (minted into + ``.dsagt/state.yaml`` at startup), so the env block never carries a + ``DSAGT_SESSION_ID`` — only project routing + embedding settings.""" from dsagt.agents import _mcp_env_block config = { "project": "test", "project_dir": "/home/user/dsagt-projects/test", - "mlflow": {"port": 5000}, "embedding": {"model": "m", "base_url": "u"}, } env = _mcp_env_block(config) - # Project routing comes from dsagt_config.yaml via cwd-walk — - # never duplicated into the MCP env block. - assert "DSAGT_PROJECT_DIR" not in env - assert "DSAGT_PROJECT" not in env + assert "DSAGT_SESSION_ID" not in env assert env["EMBEDDING_MODEL"] == "m" assert env["EMBEDDING_BASE_URL"] == "u" + # Even if a stray session_id is on the config dict, it isn't emitted. + env2 = _mcp_env_block({**config, "session_id": "test-1"}) + assert "DSAGT_SESSION_ID" not in env2 # --------------------------------------------------------------------------- @@ -1419,15 +1130,13 @@ class TestByoaEnvHints: the per-project launch shim — the user's shell stays clean.""" @pytest.mark.parametrize( - "agent_name", ["claude", "goose", "cline", "roo", "codex", "opencode"] + "agent_name", ["claude", "goose", "cline", "codex", "opencode"] ) def test_returns_only_credential_hints(self, agent_name, tmp_path): from dsagt.agents import AGENTS setup = AGENTS[agent_name]() - hints = setup.byoa_env_hints( - mlflow_port=5001, project="p", project_dir=tmp_path - ) + hints = setup.byoa_env_hints() # Only credential hints come back; no DSAGT/MLflow/OTel routing. names = [n for n, _ in hints] assert "DSAGT_PROJECT" not in names @@ -1435,13 +1144,13 @@ def test_returns_only_credential_hints(self, agent_name, tmp_path): assert "OTEL_EXPORTER_OTLP_ENDPOINT" not in names @pytest.mark.parametrize( - "agent_name", ["claude", "goose", "cline", "roo", "codex", "opencode"] + "agent_name", ["claude", "goose", "cline", "codex", "opencode"] ) def test_credentials_match_credential_hints(self, agent_name, tmp_path): from dsagt.agents import AGENTS setup = AGENTS[agent_name]() - hints = setup.byoa_env_hints(5001, "p", tmp_path) + hints = setup.byoa_env_hints() assert hints == list(setup.credential_hints) def test_goose_credentials_include_host_not_base_url(self, tmp_path): @@ -1451,7 +1160,7 @@ def test_goose_credentials_include_host_not_base_url(self, tmp_path): gateway' bug.""" from dsagt.agents import AGENTS - names = [n for n, _ in AGENTS["goose"]().byoa_env_hints(5001, "p", tmp_path)] + names = [n for n, _ in AGENTS["goose"]().byoa_env_hints()] assert "OPENAI_HOST" in names assert "ANTHROPIC_HOST" in names @@ -1460,12 +1169,11 @@ def test_goose_credentials_include_host_not_base_url(self, tmp_path): [ ("claude", "ANTHROPIC_BASE_URL"), ("codex", "OPENAI_BASE_URL"), - # Cline / roo: ``cline auth -b`` is openai-only and openai-native + # Cline: ``cline auth -b`` is openai-only and the openai-native # path needs a non-standard model env var, so we standardize on - # the anthropic path for both — same env conventions as the rest - # of the BYOA story. See cline.py / roo.py credential_hints. + # the anthropic path — same env conventions as the rest of the + # BYOA story. See cline.py credential_hints. ("cline", "ANTHROPIC_BASE_URL"), - ("roo", "ANTHROPIC_BASE_URL"), # opencode emits both — its provider config supports both wire # protocols via {env:VAR} interpolation in opencode.json. ("opencode", "OPENAI_BASE_URL"), @@ -1477,125 +1185,14 @@ def test_gateway_url_in_credential_hints(self, agent_name, gateway_var, tmp_path speaks the standard BASE_URL convention.""" from dsagt.agents import AGENTS - names = [n for n, _ in AGENTS[agent_name]().byoa_env_hints(5001, "p", tmp_path)] + names = [n for n, _ in AGENTS[agent_name]().byoa_env_hints()] assert gateway_var in names -class TestLaunchOneliner: - """``launch_oneliner`` returns the literal agent command (no shim). - The shim is a separate file (``dsagt-launch.sh``) written by - ``dynamic_agent_record`` — see TestLaunchShim below.""" - - @pytest.mark.parametrize( - "agent_name", ["claude", "goose", "cline", "roo", "codex", "opencode"] - ) - def test_oneliner_does_not_invoke_shim(self, agent_name, tmp_path): - from dsagt.agents import AGENTS - - cmd = AGENTS[agent_name]().launch_oneliner("myproj", tmp_path) - assert "dsagt-launch.sh" not in cmd - - @pytest.mark.parametrize( - "agent_name,expected_cmd", - [ - ("claude", "claude"), - ("goose", "goose session"), - ("roo", "roo"), - ("opencode", "opencode"), - ], - ) - def test_oneliner_runs_agent_directly(self, agent_name, expected_cmd, tmp_path): - from dsagt.agents import AGENTS - - cmd = AGENTS[agent_name]().launch_oneliner("myproj", tmp_path) - assert expected_cmd in cmd - assert f"cd {tmp_path}" in cmd - - def test_cline_oneliner_includes_config_flag(self, tmp_path): - """Cline's ``--config /.cline-data`` is the only way to - pick up the per-project MCP-server registrations.""" - from dsagt.agents import AGENTS - - cmd = AGENTS["cline"]().launch_oneliner("myproj", tmp_path) - assert "--config" in cmd - assert ".cline-data" in cmd - - def test_codex_oneliner_sets_codex_home(self, tmp_path): - """Codex has no ``--config`` flag — must export ``CODEX_HOME`` - before launching so codex finds the per-project config.toml.""" - from dsagt.agents import AGENTS - - cmd = AGENTS["codex"]().launch_oneliner("myproj", tmp_path) - assert "CODEX_HOME=" in cmd - assert ".codex-data" in cmd - - -# --------------------------------------------------------------------------- -# dsagt memory: high-water-mark extraction state -# --------------------------------------------------------------------------- - - -class TestMemoryWatermark: - """``dsagt memory --project X`` tracks which sessions have been - extracted in ``/.dsagt/extracted_at.json`` so re-runs only - process new traces.""" - - def test_first_run_creates_watermark_on_success(self, tmp_path, monkeypatch): - from argparse import Namespace - from dsagt.commands.cli import _cmd_memory - - pdir, _ = init_project("mem-test", "claude") - monkeypatch.setattr( - "dsagt.commands.cli.run_extraction", - lambda _project: {"status": "ok", "total_entries": 3}, - ) - _cmd_memory(Namespace(project="mem-test")) - - state_path = pdir / ".dsagt" / "extracted_at.json" - assert state_path.exists() - state = json.loads(state_path.read_text()) - assert "last_extracted_at" in state - assert state["previous"] is None - - def test_subsequent_run_records_previous_watermark(self, tmp_path, monkeypatch): - from argparse import Namespace - from dsagt.commands.cli import _cmd_memory - - pdir, _ = init_project("mem-test", "claude") - monkeypatch.setattr( - "dsagt.commands.cli.run_extraction", - lambda _project: {"status": "ok", "total_entries": 1}, - ) - _cmd_memory(Namespace(project="mem-test")) - first_state = json.loads((pdir / ".dsagt" / "extracted_at.json").read_text()) - - _cmd_memory(Namespace(project="mem-test")) - second_state = json.loads((pdir / ".dsagt" / "extracted_at.json").read_text()) - - # The previous run's mark moves into the "previous" slot. - assert second_state["previous"] == first_state["last_extracted_at"] - assert second_state["last_extracted_at"] != first_state["last_extracted_at"] - - def test_empty_extraction_does_not_advance_watermark(self, tmp_path, monkeypatch): - """When run_extraction returns status=empty, the state file is - not written — re-running later still picks up new traces.""" - from argparse import Namespace - from dsagt.commands.cli import _cmd_memory - - pdir, _ = init_project("mem-test", "claude") - monkeypatch.setattr( - "dsagt.commands.cli.run_extraction", - lambda _project: {"status": "empty"}, - ) - _cmd_memory(Namespace(project="mem-test")) - - assert not (pdir / ".dsagt" / "extracted_at.json").exists() - - -class TestLaunchShim: - """``dynamic_agent_record`` writes ``dsagt-launch.sh`` for BYOA-mode - projects. The shim starts MLflow, exports env, and prints how to - launch the agent (it does NOT exec the agent — user picks).""" +class TestNoLaunchShim: + """Phase 1 collapsed the launch surface: ``dynamic_agent_record`` + writes the MCP config but NO ``dsagt-launch.sh`` shim. The user + starts the agent directly in the project dir or via ``dsagt start``.""" def _make_config(self, agent_name: str, pdir): return { @@ -1608,103 +1205,38 @@ def _make_config(self, agent_name: str, pdir): "session_id": "sess-xyz", } - def test_shim_written_for_byoa(self, tmp_path): - """`dynamic_agent_record` writes dsagt-launch.sh in BYOA mode.""" - from dsagt.agents import dynamic_agent_record - - config = self._make_config("goose", tmp_path) - dynamic_agent_record(config, env={}, working_dir=tmp_path) - - shim = tmp_path / "dsagt-launch.sh" - assert shim.exists() - assert (shim.stat().st_mode & 0o111) != 0 # executable - - def test_shim_does_not_exec_agent(self, tmp_path): - """Shim prints launch options instead of execing — user picks.""" + @pytest.mark.parametrize("agent_name", ["goose", "claude", "codex"]) + def test_no_shim_written(self, agent_name, tmp_path): from dsagt.agents import dynamic_agent_record - config = self._make_config("goose", tmp_path) + config = self._make_config(agent_name, tmp_path) dynamic_agent_record(config, env={}, working_dir=tmp_path) - shim_text = (tmp_path / "dsagt-launch.sh").read_text() - assert "exec " not in shim_text - assert "Environment ready. Launch the agent" in shim_text - assert "goose session" in shim_text # CLI option printed + assert not (tmp_path / "dsagt-launch.sh").exists() - def test_shim_starts_mlflow_in_background(self, tmp_path): - from dsagt.agents import dynamic_agent_record - - config = self._make_config("goose", tmp_path) - dynamic_agent_record(config, env={}, working_dir=tmp_path) - - shim_text = (tmp_path / "dsagt-launch.sh").read_text() - assert "dsagt mlflow test --background-only" in shim_text - - def test_shim_for_claude_skips_otel_routing(self, tmp_path): - """Claude shim omits OTEL_EXPORTER_OTLP_* exports — agent-side - traces come from `mlflow autolog claude`'s Stop hook.""" - from dsagt.agents import dynamic_agent_record - - config = self._make_config("claude", tmp_path) - dynamic_agent_record(config, env={}, working_dir=tmp_path) - shim_text = (tmp_path / "dsagt-launch.sh").read_text() - assert "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT" not in shim_text - assert "MLFLOW_TRACKING_URI" in shim_text # still exported +class TestClaudeSetup: + """`dsagt init --agent claude` writes `.mcp.json` and does NOT wire MLflow's + autolog Stop hook — DSAGT's own serverless heartbeat pipeline (ClaudeReader → + ClaudeTranslator → MLflowSink) produces Claude's traces, uniformly with every + other agent, so wiring autolog too would double-log.""" - def test_shim_for_goose_includes_otel_routing(self, tmp_path): - """Non-Claude agents need OTel routing (no autolog analog).""" - from dsagt.agents import dynamic_agent_record - - config = self._make_config("goose", tmp_path) - dynamic_agent_record(config, env={}, working_dir=tmp_path) - - shim_text = (tmp_path / "dsagt-launch.sh").read_text() - assert "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT" in shim_text - assert "service.name=goose" in shim_text - - -class TestClaudeAutologSetup: - """`dsagt init --agent claude` configures `mlflow autolog claude` - by writing `.claude/settings.json` with the Stop hook + tracking - env vars.""" - - def test_settings_file_written(self, tmp_path): + def test_writes_mcp_json_no_autolog_hook(self, tmp_path, monkeypatch): from dsagt.agents.claude import ClaudeSetup + monkeypatch.delenv("MLFLOW_TRACKING_URI", raising=False) config = { "project": "myproj", "project_dir": str(tmp_path), "agent": "claude", - "mlflow": {"port": 5099}, "embedding": {}, "llm": {}, } actions = ClaudeSetup().write_dynamic( - config, - env={}, - working_dir=tmp_path, - pdir=tmp_path, + config, env={}, working_dir=tmp_path, pdir=tmp_path ) - settings_file = tmp_path / ".claude" / "settings.json" - assert settings_file.exists() - assert any("mlflow autolog claude" in a for a in actions) - - import json as _json - - settings = _json.loads(settings_file.read_text()) - env_block = settings.get("env") or {} - assert env_block.get("MLFLOW_TRACKING_URI") == "http://localhost:5099" - assert env_block.get("MLFLOW_EXPERIMENT_NAME") == "myproj" - assert env_block.get("MLFLOW_CLAUDE_TRACING_ENABLED") == "true" - - hooks = settings.get("hooks") or {} - stop_hooks = hooks.get("Stop") or [] - # Stop hook should reference mlflow autolog claude. - all_commands = [] - for group in stop_hooks: - for h in group.get("hooks") or []: - if h.get("command"): - all_commands.append(h["command"]) - assert any("mlflow autolog claude" in c for c in all_commands) + assert (tmp_path / ".mcp.json").exists() + # No autolog: no Stop hook, no .claude/settings.json. + assert not any("autolog" in a.lower() for a in actions) + assert not (tmp_path / ".claude" / "settings.json").exists() diff --git a/tests/test_dependency_integration.py b/tests/test_dependency_integration.py index 9db0064..4b15701 100644 --- a/tests/test_dependency_integration.py +++ b/tests/test_dependency_integration.py @@ -34,11 +34,11 @@ from dsagt.mcp.registry_tools import create_registry_server from dsagt.registry import ToolRegistry - # --------------------------------------------------------------------------- # Skip conditions # --------------------------------------------------------------------------- + def _uv_available() -> bool: return shutil.which("uv") is not None @@ -61,6 +61,7 @@ def _cowsay_installed() -> bool: # Fixtures # --------------------------------------------------------------------------- + @pytest.fixture(scope="module", autouse=True) def uninstall_cowsay_after(): """Ensure cowsay is uninstalled after all tests in this module.""" @@ -74,11 +75,11 @@ def uninstall_cowsay_after(): from mcp_helpers import call_tool_sync as call_tool - # --------------------------------------------------------------------------- # Test # --------------------------------------------------------------------------- + def test_register_and_run_tool_with_dependency(tmp_path): """End-to-end: register a tool with a dependency, install it, run the tool.""" # 1. Write a test script that imports cowsay diff --git a/tests/test_dsagt_server.py b/tests/test_dsagt_server.py index 3aa3486..a23e16a 100644 --- a/tests/test_dsagt_server.py +++ b/tests/test_dsagt_server.py @@ -53,8 +53,9 @@ def test_merged_server_exposes_all_tools(tmp_path): """Both concern modules' tools land under one server with no collision.""" server = _make_merged_server(tmp_path) names = _list_tools(server) - # 8 registry + 6 knowledge + 4 memory + 5 skill = 23 distinct tools. - assert len(names) == 23 + # 8 registry + 5 knowledge + 4 memory + 5 skill = 22 distinct tools. + # (kb_add_vector_db is gated off until BYO lands — see knowledge_tools.py.) + assert len(names) == 22 assert len(set(names)) == len(names) # no name collision # Representative tools from each side. for expected in ( @@ -94,7 +95,7 @@ class TestBuildKbFromConfig: def _cfg(self, **embedding): return { "embedding": embedding, - "knowledge": {"chunk_size": 1024, "vector_db": "chroma", "rerank": False}, + "knowledge": {"chunk_size": 1024, "rerank": False}, } def test_invalid_backend_raises(self, tmp_path): @@ -102,15 +103,15 @@ def test_invalid_backend_raises(self, tmp_path): with pytest.raises(ValueError, match="backend must be"): _build_kb_from_config(cfg, tmp_path) - def test_api_backend_without_base_url_raises(self, tmp_path): - cfg = self._cfg(backend="api", model="m", base_url="", api_key="k") + def test_api_backend_without_base_url_raises(self, tmp_path, monkeypatch): + monkeypatch.setenv("EMBEDDING_API_KEY", "k") + cfg = self._cfg(backend="api", model="m", base_url="") with pytest.raises(ValueError, match="requires embedding.base_url"): _build_kb_from_config(cfg, tmp_path) - def test_api_backend_without_api_key_raises(self, tmp_path): - # Unresolved ``${...}`` placeholder counts as missing. - cfg = self._cfg( - backend="api", model="m", base_url="http://x", api_key="${LLM_API_KEY}" - ) - with pytest.raises(ValueError, match="requires embedding.api_key"): + def test_api_backend_without_api_key_raises(self, tmp_path, monkeypatch): + # Credentials come from the EMBEDDING_API_KEY env var, never on disk. + monkeypatch.delenv("EMBEDDING_API_KEY", raising=False) + cfg = self._cfg(backend="api", model="m", base_url="http://x") + with pytest.raises(ValueError, match="requires the EMBEDDING_API_KEY"): _build_kb_from_config(cfg, tmp_path) diff --git a/tests/test_episodic_integration.py b/tests/test_episodic_integration.py new file mode 100644 index 0000000..905d2c2 --- /dev/null +++ b/tests/test_episodic_integration.py @@ -0,0 +1,122 @@ +"""End-to-end episodic-memory integration: heartbeat → judge → session_memory. + +The full Phase-3 chain with nothing faked: a real Claude transcript on disk → +``TraceCollector`` (the heartbeat) → the ``MemoryExtractor`` consumer built by the +server's own ``_episodic_consumers`` wiring → the real ``LocalJudge`` (GGUF +inference) → distilled facts embedded into the ``session_memory`` collection of a +real ``KnowledgeBase`` → retrieved by ``kb.search``. + +Marked ``integration``: loads the local embedder and downloads/loads the ~1GB +GGUF judge, so it's deselected from the fast suite (``-m 'not integration'``). +""" + +import json + +import pytest + +from dsagt.knowledge import KnowledgeBase +from dsagt.memory import SESSION_MEMORY_COLLECTION +from dsagt.traces import _transcript_dir, make_trace_collector + + +def _asst(ts, text): + return { + "type": "assistant", + "timestamp": ts, + "message": { + "role": "assistant", + "model": "claude-x", + "content": [{"type": "text", "text": text}], + "usage": {"input_tokens": 5, "output_tokens": 2}, + }, + } + + +def _user(ts, text, uuid): + return { + "type": "user", + "timestamp": ts, + "uuid": uuid, + "message": {"role": "user", "content": text}, + } + + +@pytest.mark.integration +def test_heartbeat_distills_a_fact_into_session_memory(tmp_path): + project_dir = tmp_path / "proj" + (project_dir / ".dsagt").mkdir(parents=True) + + # A real KB with the local embedder (the project's session_memory lives here). + kb = KnowledgeBase(index_dir=project_dir / "kb_index", default_embedder="local") + + # Episodic enabled with the Tier-1 local judge — exactly what `dsagt init + # --episodic` writes — fed through the server's real subscriber builder. + from dsagt.mcp.server import _episodic_consumers + + config = { + "episodic": { + "enabled": True, + "judge": {"backend": "local", "model": ""}, + "domain_tags": {}, + "outlier_sensitivity": 0.0, + } + } + subs = _episodic_consumers(config, kb, project_dir, "proj:s") + assert subs and subs[0].name == "memory" + + # A real Claude transcript with one clearly fact-bearing completed turn, + # then an open turn so the first is a "completed" candidate for the tick. + proot = tmp_path / "projects" + tdir = _transcript_dir(project_dir, proot) + tdir.mkdir(parents=True) + transcript = tdir / "sess.jsonl" + records = [ + _user( + "2026-06-28T15:00:00.000Z", + "Run fastp on the reads at a Q30 quality threshold.", + "u1", + ), + _asst( + "2026-06-28T15:00:01.000Z", + "Done — fastp filtered the reads at Q30; 92% of reads passed and were written to clean.fq.gz.", + ), + _user("2026-06-28T15:00:02.000Z", "what next?", "u2"), # opens turn 2 + ] + with open(transcript, "w") as fh: + for r in records: + fh.write(json.dumps(r) + "\n") + + tracking_uri = f"sqlite:///{tmp_path / 'mlflow.db'}" + collector = make_trace_collector( + "claude", + project_dir, + "proj", + "proj:s", + tracking_uri, + projects_root=proot, + extra_consumers=subs, + ) + + # include_last=True flushes both turns; the memory consumer distills them. + n = collector.collect(include_last=True) + assert n >= 1 + + # Both consumers advanced their own marks independently. + assert collector._load_acks("mlflow") # observability sink + assert collector._load_acks("memory") # episodic consumer + + # The distilled fact is retrievable from session_memory, tagged as a Tier-1 + # fact from this session. + hits = kb.search( + "fastp quality filtering Q30", collection=SESSION_MEMORY_COLLECTION, top_k=5 + ) + assert hits, "expected at least one distilled fact in session_memory" + chunk = hits[0]["chunk"] + text = chunk["text"].lower() + assert "fastp" in text or "q30" in text or "qc" in text + meta = chunk["metadata"] + assert meta.get("source_type") == "fact" + assert meta.get("tier") == "1" + assert meta.get("session_id") == "proj:s" + + kb.close() diff --git a/tests/test_extraction.py b/tests/test_extraction.py index 23177e9..a29a126 100644 --- a/tests/test_extraction.py +++ b/tests/test_extraction.py @@ -1,44 +1,26 @@ """ -Tests for memory extraction (post-proxy). +Tests for memory-extraction helpers. -Conversation history now comes from MLflow traces, not a local -``session_log.jsonl``. Tests inject ``exchanges=[...]`` directly into -``extract_session`` so they don't need to mock the MLflow SDK; the -MLflow-trace-to-exchange formatter is exercised by its own unit tests -on ``_trace_to_exchange``. +Episodic extraction itself (``extract_session``) is a no-op stub post-proxy +— the proxy-shape trace reader and the LiteLLM extraction call were removed +with the proxy, and Phase 3 rebuilds extraction over the CanonicalTrace +pipeline. The prompt builder and response parser are retained as Phase-3 +building blocks and still have unit coverage here. """ import json -from unittest.mock import MagicMock, patch - -import numpy as np -import pytest from dsagt.memory import ( - EPISODIC_COLLECTION as COLLECTION_NAME, - _trace_to_exchange, build_extraction_prompt, extract_session, parse_extraction_response, ) -from dsagt.knowledge import KnowledgeBase - - -def fake_embed(texts: list[str]) -> np.ndarray: - dim = 8 - vecs = np.zeros((len(texts), dim), dtype=np.float32) - for i, t in enumerate(texts): - rng = np.random.RandomState(hash(t) & 0xFFFFFFFF) - vecs[i] = rng.randn(dim).astype(np.float32) - norms = np.linalg.norm(vecs, axis=1, keepdims=True) - norms[norms == 0] = 1 - return vecs / norms - # --------------------------------------------------------------------------- # build_extraction_prompt # --------------------------------------------------------------------------- + class TestBuildExtractionPrompt: def _make_exchanges(self): @@ -46,24 +28,38 @@ def _make_exchanges(self): { "timestamp": "2024-01-15T10:30:00Z", "model": "claude-sonnet-4-20250514", - "new_messages": [{"role": "user", "content": "Run fastp on sample1.fq.gz"}], + "new_messages": [ + {"role": "user", "content": "Run fastp on sample1.fq.gz"} + ], "response": [ {"type": "text", "text": "I'll run fastp with Q20 filtering."}, - {"type": "tool_use", "name": "bash", - "input": {"cmd": "fastp -q 20 --in1 sample1.fq.gz"}}, + { + "type": "tool_use", + "name": "bash", + "input": {"cmd": "fastp -q 20 --in1 sample1.fq.gz"}, + }, ], }, { "timestamp": "2024-01-15T10:31:00Z", "model": "claude-sonnet-4-20250514", "new_messages": [ - {"role": "user", "content": [ - {"type": "tool_result", "tool_use_id": "t1", - "content": "98% reads passed"}, - ]}, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "t1", + "content": "98% reads passed", + }, + ], + }, ], "response": [ - {"type": "text", "text": "Filtering complete. 98% of reads passed Q20."}, + { + "type": "text", + "text": "Filtering complete. 98% of reads passed Q20.", + }, ], }, ] @@ -101,24 +97,39 @@ def test_empty_exchanges(self): # parse_extraction_response # --------------------------------------------------------------------------- + class TestParseExtractionResponse: def test_valid_json(self): - response = json.dumps({ - "facts": [{"text": "fastp used Q20", "category": "quality_control"}], - "summary": "Ran fastp on sample1.", - "insights": [{"text": "Q20 is sufficient for isolates", - "category": "quality_control"}], - }) + response = json.dumps( + { + "facts": [{"text": "fastp used Q20", "category": "quality_control"}], + "summary": "Ran fastp on sample1.", + "insights": [ + { + "text": "Q20 is sufficient for isolates", + "category": "quality_control", + } + ], + } + ) result = parse_extraction_response(response) assert len(result["facts"]) == 1 assert result["summary"] == "Ran fastp on sample1." assert len(result["insights"]) == 1 def test_strips_markdown_fences(self): - response = "```json\n" + json.dumps({ - "facts": [], "summary": "test", "insights": [], - }) + "\n```" + response = ( + "```json\n" + + json.dumps( + { + "facts": [], + "summary": "test", + "insights": [], + } + ) + + "\n```" + ) result = parse_extraction_response(response) assert result["summary"] == "test" @@ -130,170 +141,34 @@ def test_missing_fields_default(self): # --------------------------------------------------------------------------- -# _trace_to_exchange — MLflow trace row → extraction exchange dict +# extract_session — Phase-3 stub # --------------------------------------------------------------------------- -class TestTraceToExchange: - - def test_anthropic_response_shape(self): - """Anthropic-shape response: ``content`` is already a block list.""" - row = { - "request_time": "2024-05-01T12:00:00Z", - "trace_id": "tr-abc", - "request": json.dumps({ - "model": "claude-sonnet-4-5", - "messages": [{"role": "user", "content": "hi"}], - }), - "response": json.dumps({ - "content": [{"type": "text", "text": "hello"}], - }), - } - ex = _trace_to_exchange(row) - assert ex is not None - assert ex["new_messages"] == [{"role": "user", "content": "hi"}] - assert ex["response"] == [{"type": "text", "text": "hello"}] - assert ex["trace_id"] == "tr-abc" - assert ex["model"] == "claude-sonnet-4-5" - - def test_openai_response_shape_with_tool_calls(self): - """OpenAI-shape: choices[].message.content + tool_calls.""" - row = { - "request_time": "2024-05-01T12:00:00Z", - "trace_id": "tr-xyz", - "request": json.dumps({ - "model": "gpt-4o", - "messages": [{"role": "user", "content": "run it"}], - }), - "response": json.dumps({ - "choices": [{ - "message": { - "content": "Sure", - "tool_calls": [{ - "id": "call_1", - "function": { - "name": "bash", - "arguments": json.dumps({"cmd": "ls"}), - }, - }], - }, - }], - }), - } - ex = _trace_to_exchange(row) - assert ex is not None - types = [b["type"] for b in ex["response"]] - assert "text" in types - assert "tool_use" in types - tool_use = next(b for b in ex["response"] if b["type"] == "tool_use") - assert tool_use["name"] == "bash" - assert tool_use["input"] == {"cmd": "ls"} - def test_unrecognised_shape_returns_none(self): - """Non-LLM-call traces (kb.* / tool.execute spans) get skipped.""" - row = { - "request_time": "2024-05-01T12:00:00Z", - "trace_id": "tr-kb", - "request": json.dumps({"query": "stuff"}), # no messages - "response": "{}", - } - assert _trace_to_exchange(row) is None - - def test_handles_missing_response(self): - row = { - "trace_id": "tr-empty", - "request": json.dumps({ - "messages": [{"role": "user", "content": "x"}], - }), - "response": None, - } - ex = _trace_to_exchange(row) - assert ex is not None - assert ex["response"] == [] - - -# --------------------------------------------------------------------------- -# extract_session (end-to-end with injected exchanges) -# --------------------------------------------------------------------------- - -class TestExtractSession: - - def _mock_llm_response(self): - return json.dumps({ - "facts": [ - {"text": "fastp was run with Q20 on sample1", - "category": "quality_control"}, - {"text": "98% of reads passed filtering", "category": "results"}, - ], - "summary": "Ran quality filtering on sample1 using fastp with Q20 threshold.", - "insights": [ - {"text": "Q20 filtering is sufficient for high-quality isolate data", - "category": "quality_control"}, - ], - }) - - def test_extracts_and_stores(self, tmp_path): - """End-to-end: injected exchanges → mocked LLM → KB write.""" - exchanges = [ - { - "timestamp": "2024-01-15T10:30:00Z", - "trace_id": "tr-1", - "model": "m", - "new_messages": [{"role": "user", "content": "run fastp"}], - "response": [{"type": "text", "text": "Running fastp."}], - }, - ] - with patch("dsagt.knowledge._make_embedder") as mock_make: - mock_embedder = MagicMock() - mock_embedder.embed = fake_embed - mock_make.return_value = mock_embedder - - kb = KnowledgeBase(index_dir=tmp_path / "kb") - with patch("dsagt.memory.call_extraction_llm") as mock_llm: - mock_llm.return_value = self._mock_llm_response() - result = extract_session( - project_name="proj", - kb=kb, - api_key="test-key", - session_id="test-session", - exchanges=exchanges, - ) - assert result["status"] == "ok" - assert result["facts"] == 2 - assert result["insights"] == 1 - assert result["summary"] == 1 - kb.close() - - def test_empty_exchanges_returns_status_empty(self, tmp_path): - """No exchanges → returns status=empty, no LLM call.""" - with patch("dsagt.knowledge._make_embedder") as mock_make: - mock_embedder = MagicMock() - mock_embedder.embed = fake_embed - mock_make.return_value = mock_embedder - - kb = KnowledgeBase(index_dir=tmp_path / "kb") - with patch("dsagt.memory.call_extraction_llm") as mock_llm: - result = extract_session( - project_name="proj", - kb=kb, - api_key="test-key", - session_id="test-session", - exchanges=[], - ) - mock_llm.assert_not_called() - assert result["status"] == "empty" - kb.close() - - def test_missing_session_id_returns_empty(self, tmp_path): - """``session_id`` is required when ``exchanges`` isn't supplied.""" - with patch("dsagt.knowledge._make_embedder") as mock_make: - mock_embedder = MagicMock() - mock_embedder.embed = fake_embed - mock_make.return_value = mock_embedder - - kb = KnowledgeBase(index_dir=tmp_path / "kb") - result = extract_session( - project_name="proj", kb=kb, api_key="test-key", - ) - assert result["status"] == "empty" - assert result.get("reason") == "no_session_id" - kb.close() +class TestExtractSessionStub: + """Until Phase 3 rebuilds extraction, ``extract_session`` is a no-op + that reports unavailability without reading traces or calling an LLM.""" + + def test_returns_unavailable_without_touching_kb_or_network(self): + result = extract_session( + project_name="proj", + kb=None, + api_key="test-key", + session_id="test-session", + ) + assert result["status"] == "extraction_unavailable" + assert result["facts"] == 0 + assert result["insights"] == 0 + assert result["total_entries"] == 0 + assert result["session_id"] == "test-session" + + def test_stub_ignores_injected_exchanges(self): + result = extract_session( + project_name="proj", + kb=None, + api_key="test-key", + session_id="s", + exchanges=[{"new_messages": [], "response": []}], + ) + assert result["status"] == "extraction_unavailable" + assert result["total_entries"] == 0 diff --git a/tests/test_info.py b/tests/test_info.py index afb3f9f..3a268b0 100644 --- a/tests/test_info.py +++ b/tests/test_info.py @@ -22,18 +22,19 @@ ) -def _metadata(*, session: str, agent: str | None, - in_t: int, out_t: int) -> dict: +def _metadata(*, session: str, agent: str | None, in_t: int, out_t: int) -> dict: """Build a trace_metadata dict in MLflow's on-disk shape.""" md = {"mlflow.trace.session": session} if agent is not None: md["dsagt.agent"] = agent if in_t or out_t: - md["mlflow.trace.tokenUsage"] = json.dumps({ - "input_tokens": in_t, - "output_tokens": out_t, - "total_tokens": in_t + out_t, - }) + md["mlflow.trace.tokenUsage"] = json.dumps( + { + "input_tokens": in_t, + "output_tokens": out_t, + "total_tokens": in_t + out_t, + } + ) return md @@ -47,6 +48,7 @@ def _spans_for(service_name: str | None) -> list: through to ``"unknown"``. """ from types import SimpleNamespace + if service_name is None: return [] return [SimpleNamespace(attributes={"service.name": service_name})] @@ -66,6 +68,7 @@ def _traces_df(rows: list[dict]) -> pd.DataFrame: # _tokens / _fmt_count / _is_error primitives # --------------------------------------------------------------------------- + def test_tokens_missing_returns_zeros(): assert _tokens({}) == (0, 0) @@ -79,18 +82,29 @@ def test_tokens_extracts_input_output(): assert _tokens(md) == (123, 45) -@pytest.mark.parametrize("n,expected", [ - (0, "0"), (999, "999"), (1000, "1.0k"), - (12345, "12.3k"), (1_500_000, "1.5M"), -]) +@pytest.mark.parametrize( + "n,expected", + [ + (0, "0"), + (999, "999"), + (1000, "1.0k"), + (12345, "12.3k"), + (1_500_000, "1.5M"), + ], +) def test_fmt_count(n, expected): assert _fmt_count(n) == expected -@pytest.mark.parametrize("state,expected", [ - ("OK", False), ("ERROR", True), - ("TraceState.OK", False), ("TraceState.ERROR", True), -]) +@pytest.mark.parametrize( + "state,expected", + [ + ("OK", False), + ("ERROR", True), + ("TraceState.OK", False), + ("TraceState.ERROR", True), + ], +) def test_is_error_handles_enum_reprs(state, expected): assert _is_error(state) is expected @@ -99,11 +113,14 @@ def test_is_error_handles_enum_reprs(state, expected): # _report end-to-end # --------------------------------------------------------------------------- + @pytest.fixture def config(): return { "agent": "claude", - "llm": {"model": "claude-haiku-test"}, + # BYOA: dsagt no longer records the agent's LLM model; the info + # header surfaces the embedding model dsagt configures. + "embedding": {"model": "bge-test"}, } @@ -114,7 +131,7 @@ def test_report_empty_traces(config): assert r["by_session"] == [] assert r["errors"] == [] assert r["agent"] == "claude" - assert r["model"] == "claude-haiku-test" + assert r["model"] == "bge-test" def test_report_aggregates_by_source_and_session(config): @@ -124,44 +141,58 @@ def test_report_aggregates_by_source_and_session(config): knowledge-server trace, across two sessions. Sums + bucket counts match expected totals. """ - df = _traces_df([ - { - "trace_id": "t1", - "state": "OK", - "request_time": 100, - "trace_metadata": _metadata( - session="sess-A", agent="claude", in_t=1000, out_t=100, - ), - "spans": _spans_for("claude-code"), - }, - { - "trace_id": "t2", - "state": "OK", - "request_time": 200, - "trace_metadata": _metadata( - session="sess-A", agent="claude", in_t=500, out_t=50, - ), - "spans": _spans_for("claude-code"), - }, - { - "trace_id": "t3", - "state": "OK", - "request_time": 150, - "trace_metadata": _metadata( - session="sess-A", agent="claude", in_t=200, out_t=0, - ), - "spans": _spans_for("dsagt-server"), - }, - { - "trace_id": "t4", - "state": "ERROR", - "request_time": 300, - "trace_metadata": _metadata( - session="sess-B", agent="claude", in_t=800, out_t=20, - ), - "spans": _spans_for("claude-code"), - }, - ]) + df = _traces_df( + [ + { + "trace_id": "t1", + "state": "OK", + "request_time": 100, + "trace_metadata": _metadata( + session="sess-A", + agent="claude", + in_t=1000, + out_t=100, + ), + "spans": _spans_for("claude-code"), + }, + { + "trace_id": "t2", + "state": "OK", + "request_time": 200, + "trace_metadata": _metadata( + session="sess-A", + agent="claude", + in_t=500, + out_t=50, + ), + "spans": _spans_for("claude-code"), + }, + { + "trace_id": "t3", + "state": "OK", + "request_time": 150, + "trace_metadata": _metadata( + session="sess-A", + agent="claude", + in_t=200, + out_t=0, + ), + "spans": _spans_for("dsagt-server"), + }, + { + "trace_id": "t4", + "state": "ERROR", + "request_time": 300, + "trace_metadata": _metadata( + session="sess-B", + agent="claude", + in_t=800, + out_t=20, + ), + "spans": _spans_for("claude-code"), + }, + ] + ) r = _report("proj", config, df) @@ -193,17 +224,22 @@ def test_report_aggregates_by_source_and_session(config): def test_report_missing_source_falls_back_to_unknown(config): """No spans column / no service.name → bucket is "unknown".""" - df = _traces_df([ - { - "trace_id": "t1", - "state": "OK", - "request_time": 100, - "trace_metadata": _metadata( - session="sess-A", agent=None, in_t=0, out_t=0, - ), - "spans": _spans_for(None), - }, - ]) + df = _traces_df( + [ + { + "trace_id": "t1", + "state": "OK", + "request_time": 100, + "trace_metadata": _metadata( + session="sess-A", + agent=None, + in_t=0, + out_t=0, + ), + "spans": _spans_for(None), + }, + ] + ) r = _report("proj", config, df) assert r["by_source"][0]["source"] == "unknown" assert r["by_session"][0]["agent"] == "-" @@ -213,6 +249,7 @@ def test_report_missing_source_falls_back_to_unknown(config): # Config source tracking (config / shell / unresolved) # --------------------------------------------------------------------------- + def test_mask_secret_short_value(): assert _mask_secret("short") == "***" assert _mask_secret("exactlytwelv") == "***" @@ -223,13 +260,13 @@ def test_mask_secret_long_value(): def _write_project(tmp_path, monkeypatch, raw_yaml: str): - """Register a temp project with the given dsagt_config.yaml content.""" + """Register a temp project with the given .dsagt/config.yaml content.""" import yaml as _yaml from dsagt.session import register_project pdir = tmp_path / "proj" - pdir.mkdir() - (pdir / "dsagt_config.yaml").write_text(raw_yaml) + (pdir / ".dsagt").mkdir(parents=True) + (pdir / ".dsagt" / "config.yaml").write_text(raw_yaml) registry_dir = tmp_path / "registry" registry_dir.mkdir() @@ -242,20 +279,30 @@ def _write_project(tmp_path, monkeypatch, raw_yaml: str): def test_config_sources_classifies_shell(tmp_path, monkeypatch): """${VAR} resolves from os.environ — that's the only source for user-provided values now (no .env file is consulted).""" - _write_project(tmp_path, monkeypatch, - "project: proj\nagent: goose\nllm:\n provider: ${LLM_PROVIDER}\n model: ${LLM_MODEL}\n") + _write_project( + tmp_path, + monkeypatch, + "project: proj\nagent: goose\nllm:\n provider: ${LLM_PROVIDER}\n model: ${LLM_MODEL}\n", + ) monkeypatch.setenv("LLM_PROVIDER", "openai") monkeypatch.setenv("LLM_MODEL", "gpt-4") rows = {r["path"]: r for r in _config_sources("proj")} - assert rows["llm.provider"] == {"path": "llm.provider", "value": "openai", "source": "shell"} + assert rows["llm.provider"] == { + "path": "llm.provider", + "value": "openai", + "source": "shell", + } assert rows["llm.model"]["value"] == "gpt-4" assert rows["llm.model"]["source"] == "shell" def test_config_sources_classifies_unresolved(tmp_path, monkeypatch): - _write_project(tmp_path, monkeypatch, - "project: proj\nagent: goose\nllm:\n provider: ${LLM_PROVIDER}\n") + _write_project( + tmp_path, + monkeypatch, + "project: proj\nagent: goose\nllm:\n provider: ${LLM_PROVIDER}\n", + ) monkeypatch.delenv("LLM_PROVIDER", raising=False) rows = {r["path"]: r for r in _config_sources("proj")} @@ -264,16 +311,24 @@ def test_config_sources_classifies_unresolved(tmp_path, monkeypatch): def test_config_sources_classifies_literal(tmp_path, monkeypatch): - _write_project(tmp_path, monkeypatch, - "project: proj\nagent: goose\nproxy:\n port: 4000\n") + _write_project( + tmp_path, monkeypatch, "project: proj\nagent: goose\nmlflow:\n port: 4000\n" + ) rows = {r["path"]: r for r in _config_sources("proj")} - assert rows["proxy.port"] == {"path": "proxy.port", "value": "4000", "source": "config"} + assert rows["mlflow.port"] == { + "path": "mlflow.port", + "value": "4000", + "source": "config", + } def test_config_sources_masks_api_key(tmp_path, monkeypatch): - _write_project(tmp_path, monkeypatch, - "project: proj\nagent: goose\nllm:\n api_key: ${LLM_API_KEY}\n") + _write_project( + tmp_path, + monkeypatch, + "project: proj\nagent: goose\nllm:\n api_key: ${LLM_API_KEY}\n", + ) monkeypatch.setenv("LLM_API_KEY", "sk-1234567890abcdef") rows = {r["path"]: r for r in _config_sources("proj")} @@ -282,13 +337,15 @@ def test_config_sources_masks_api_key(tmp_path, monkeypatch): def test_config_sources_skips_internal_sections(tmp_path, monkeypatch): - _write_project(tmp_path, monkeypatch, - "project: proj\nagent: goose\nknowledge:\n chunk_size: 1024\nextraction:\n threshold: 0\ncategories:\n qc: stuff\n") + _write_project( + tmp_path, + monkeypatch, + "project: proj\nagent: goose\nknowledge:\n chunk_size: 1024\n rerank: false\nskills:\n populate_native: true\n", + ) paths = {r["path"] for r in _config_sources("proj")} assert "knowledge.chunk_size" not in paths - assert "extraction.threshold" not in paths - assert "categories.qc" not in paths + assert "skills.populate_native" not in paths assert "project" in paths @@ -296,6 +353,7 @@ def test_config_sources_skips_internal_sections(tmp_path, monkeypatch): # _kb_collections — read chunks.jsonl, count entries, break down by source # --------------------------------------------------------------------------- + def _write_chunk(path, text="x", source=None): """Append one chunks.jsonl line with optional metadata.source.""" md = {"chunk_index": 0} @@ -307,6 +365,7 @@ def _write_chunk(path, text="x", source=None): def test_kb_collections_empty_when_no_kb_index(tmp_path): from dsagt.commands.info import _kb_collections + assert _kb_collections(tmp_path) == [] @@ -355,9 +414,11 @@ def test_kb_collections_skips_dirs_without_chunks_jsonl(tmp_path): # _kb_retrieval — pull kb.search spans out of a synthetic traces frame # --------------------------------------------------------------------------- + def _trace_with_kb_spans(session: str, hits_per_search: list[int]) -> dict: """Build a single trace row carrying one kb.search span per hits entry.""" from types import SimpleNamespace + spans = [ SimpleNamespace(name="kb.search", attributes={"hits": h}) for h in hits_per_search @@ -375,6 +436,7 @@ def _trace_with_kb_spans(session: str, hits_per_search: list[int]) -> dict: def test_kb_retrieval_empty_when_no_traces(): from dsagt.commands.info import _kb_retrieval + assert _kb_retrieval(None) == [] assert _kb_retrieval(pd.DataFrame()) == [] @@ -382,11 +444,13 @@ def test_kb_retrieval_empty_when_no_traces(): def test_kb_retrieval_aggregates_searches_and_hits_by_session(): from dsagt.commands.info import _kb_retrieval - df = pd.DataFrame([ - _trace_with_kb_spans("sess-A", hits_per_search=[3, 5]), - _trace_with_kb_spans("sess-A", hits_per_search=[2]), - _trace_with_kb_spans("sess-B", hits_per_search=[7]), - ]) + df = pd.DataFrame( + [ + _trace_with_kb_spans("sess-A", hits_per_search=[3, 5]), + _trace_with_kb_spans("sess-A", hits_per_search=[2]), + _trace_with_kb_spans("sess-B", hits_per_search=[7]), + ] + ) rows = {r["session"]: r for r in _kb_retrieval(df)} assert rows["sess-A"] == {"session": "sess-A", "searches": 3, "hits": 10} assert rows["sess-B"] == {"session": "sess-B", "searches": 1, "hits": 7} @@ -396,13 +460,17 @@ def test_kb_retrieval_handles_missing_session(): from dsagt.commands.info import _kb_retrieval from types import SimpleNamespace - df = pd.DataFrame([{ - "trace_id": "t1", - "state": "OK", - "request_time": 1, - "trace_metadata": {}, # no mlflow.trace.session - "spans": [SimpleNamespace(name="kb.search", attributes={"hits": 4})], - }]) + df = pd.DataFrame( + [ + { + "trace_id": "t1", + "state": "OK", + "request_time": 1, + "trace_metadata": {}, # no mlflow.trace.session + "spans": [SimpleNamespace(name="kb.search", attributes={"hits": 4})], + } + ] + ) rows = _kb_retrieval(df) assert len(rows) == 1 assert rows[0]["session"] == "(no-session)" @@ -413,16 +481,20 @@ def test_kb_retrieval_ignores_non_kb_spans(): from dsagt.commands.info import _kb_retrieval from types import SimpleNamespace - df = pd.DataFrame([{ - "trace_id": "t1", - "state": "OK", - "request_time": 1, - "trace_metadata": {"mlflow.trace.session": "s"}, - "spans": [ - SimpleNamespace(name="kb.embed", attributes={}), - SimpleNamespace(name="registry.save_tool_spec", attributes={}), - ], - }]) + df = pd.DataFrame( + [ + { + "trace_id": "t1", + "state": "OK", + "request_time": 1, + "trace_metadata": {"mlflow.trace.session": "s"}, + "spans": [ + SimpleNamespace(name="kb.embed", attributes={}), + SimpleNamespace(name="registry.save_tool_spec", attributes={}), + ], + } + ] + ) assert _kb_retrieval(df) == [] @@ -430,6 +502,7 @@ def test_kb_retrieval_ignores_non_kb_spans(): # _project_created — best-effort project-start date # --------------------------------------------------------------------------- + def test_project_created_returns_iso_date_for_existing_dir(tmp_path): from dsagt.commands.info import _project_created @@ -443,4 +516,5 @@ def test_project_created_returns_iso_date_for_existing_dir(tmp_path): def test_project_created_returns_none_when_dir_missing(tmp_path): from dsagt.commands.info import _project_created + assert _project_created(tmp_path / "does-not-exist") is None diff --git a/tests/test_integration.py b/tests/test_integration.py index 6cdfbe7..368cc04 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -9,8 +9,8 @@ Useful when debugging embedding failures, because the smoke test can't tell you whether a failure came from the network or the KB pipeline. - **TestLLMEndpoint** — the raw LLM API returns a completion. Same - reasoning: bisects "is the upstream reachable" from "is the proxy wired - correctly". + reasoning: bisects "is the upstream reachable" from "is the KB/agent + layer wired correctly". - **TestConfigChain** — dsagt init → static_agent_record → dynamic_agent_record → agent_env without touching the network, since config bugs can masquerade as runtime errors that are painful to diagnose via smoke. @@ -25,7 +25,7 @@ import numpy as np import pytest -from dsagt.knowledge import APIEmbeddingClient +from dsagt.knowledge import APIEmbedder pytestmark = pytest.mark.integration @@ -34,12 +34,13 @@ # Embedding endpoint # --------------------------------------------------------------------------- + class TestEmbeddingEndpoint: """Validates the embedding API is reachable and returns valid vectors.""" def test_embedding_returns_vector(self, embedding_config): """Single text produces a numpy array with positive dimension.""" - client = APIEmbeddingClient( + client = APIEmbedder( model=embedding_config["model"], base_url=embedding_config["base_url"], api_key=embedding_config["api_key"], @@ -55,7 +56,7 @@ def test_embedding_returns_vector(self, embedding_config): def test_embedding_batch(self, embedding_config): """Multiple texts produce correct batch shape with consistent dim.""" - client = APIEmbeddingClient( + client = APIEmbedder( model=embedding_config["model"], base_url=embedding_config["base_url"], api_key=embedding_config["api_key"], @@ -77,14 +78,13 @@ def test_embedding_batch(self, embedding_config): # LLM endpoint # --------------------------------------------------------------------------- + class TestLLMEndpoint: """Validates the LLM API is reachable and returns valid responses. Posts to ``/chat/completions`` (OpenAI-shape) because .env's LLM_BASE_URL is an OpenAI-compatible gateway — Anthropic-format ``/v1/messages`` would - 404 on it. The proxy (commands/proxy_server.py) sets - ``use_chat_completions_url_for_anthropic_messages = True`` for the same - reason. + 404 on it. """ def test_llm_returns_response(self, llm_config): @@ -92,7 +92,11 @@ def test_llm_returns_response(self, llm_config): # Some gateways expect /v1 already in base_url (RC Chat / Ollama # OpenAI-compat), others don't (ai-incubator-api). Don't double it. base = llm_config["base_url"].rstrip("/") - url = f"{base}/chat/completions" if base.endswith("/v1") else f"{base}/v1/chat/completions" + url = ( + f"{base}/chat/completions" + if base.endswith("/v1") + else f"{base}/v1/chat/completions" + ) with httpx.Client(timeout=30.0) as client: response = client.post( url, @@ -119,12 +123,13 @@ def test_llm_returns_response(self, llm_config): # Config chain (no network) # --------------------------------------------------------------------------- + class TestConfigChain: """Validates the init → config generation → env var chain (BYOA).""" def test_init_generates_valid_config(self, tmp_path, monkeypatch): - """init_project creates a valid dsagt_config.yaml and returns - ``(pdir, mlflow_port)``.""" + """init_project creates a valid .dsagt/config.yaml and returns the + project dir (serverless — no port).""" import yaml from dsagt.session import init_project @@ -132,71 +137,18 @@ def test_init_generates_valid_config(self, tmp_path, monkeypatch): reg = {} monkeypatch.setattr("dsagt.session._load_registry", lambda: dict(reg)) monkeypatch.setattr("dsagt.session._save_registry", lambda r: reg.update(r)) - monkeypatch.setattr("dsagt.session.register_project", lambda n, p: reg.update({n: str(p)})) + monkeypatch.setattr( + "dsagt.session.register_project", lambda n, p: reg.update({n: str(p)}) + ) - pdir, port = init_project("test-proj", "claude") + pdir = init_project("test-proj", "claude") - assert isinstance(port, int) and port > 0 - config_path = pdir / "dsagt_config.yaml" + config_path = pdir / ".dsagt" / "config.yaml" assert config_path.exists() config = yaml.safe_load(config_path.read_text()) assert config["project"] == "test-proj" assert config["agent"] == "claude" - assert config["mlflow"]["port"] == port + assert "mlflow" not in config assert "embedding" in config - # BYOA: no llm: block in the user-facing YAML. + # BYOA: no llm: block in the config. assert "llm" not in config - - -@pytest.mark.skip(reason="proxy mode deferred to Phase 2") -class TestProxyConfigChain: - """Proxy-mode env-routing matrix. Re-enable when --proxy_traces is - restored in Phase 2 — these tests are the regression net for the - 'agent talks to upstream instead of the proxy' bug class.""" - - def test_agent_configs_have_correct_ports(self, integration_config, tmp_path): - from dsagt.agents import agent_env, dynamic_agent_record, static_agent_record - - working_dir = tmp_path / "workdir" - working_dir.mkdir() - - static_agent_record(integration_config, integration_config["agent"], working_dir) - env = agent_env(integration_config) - dynamic_agent_record(integration_config, env, working_dir) - # Phase 2: assert proxy URL lands in the per-agent MCP-config artifact - # (cline_mcp_settings.json / .roo/mcp.json / codex config.toml). - - def test_env_vars_chain_complete(self, integration_config): - from dsagt.agents import agent_env - - env = agent_env(integration_config) - assert env["DSAGT_PROJECT"] == integration_config["project"] - assert env["DSAGT_AGENT"] == integration_config["agent"] - if integration_config["agent"] == "claude": - proxy_port = integration_config["proxy"]["port"] - assert str(proxy_port) in env["ANTHROPIC_BASE_URL"] - - @pytest.mark.parametrize("agent", ["goose", "claude", "roo", "cline", "codex"]) - def test_agent_env_matrix(self, integration_config, agent): - from dsagt.agents import agent_env - - config = dict(integration_config) - config["agent"] = agent - proxy_port = config["proxy"]["port"] - model = config["llm"]["model"] - sentinel = "dsagt-proxy-forwarded-disable-direct-calls" - - env = agent_env(config) - assert env["DSAGT_AGENT"] == agent - - if agent in ("claude", "roo"): - assert env["ANTHROPIC_BASE_URL"] == f"http://localhost:{proxy_port}" - assert env["ANTHROPIC_MODEL"] == model - assert env["ANTHROPIC_API_KEY"] == sentinel - elif agent == "goose": - assert env["OPENAI_HOST"] == f"http://localhost:{proxy_port}" - assert env["GOOSE_PROVIDER"] == "openai" - assert env["OPENAI_API_KEY"] == sentinel - elif agent == "codex": - assert env["CODEX_HOME"].endswith(".codex-data") - assert env["OPENAI_API_KEY"] == sentinel diff --git a/tests/test_judge.py b/tests/test_judge.py new file mode 100644 index 0000000..db81fb6 --- /dev/null +++ b/tests/test_judge.py @@ -0,0 +1,124 @@ +"""Tests for the episodic-memory Judge (Phase 3, Tier-1). + +The backend ``distill`` is a no-op for now; what's contractual today is the +factory, the lean per-turn prompt, and the tolerant response parser. +""" + +import pytest + +from dsagt.judge import ( + APIJudge, + Judge, + LocalJudge, + build_distill_prompt, + parse_distill_response, +) + +TAGS = { + "performance": "Runtime, memory usage, throughput", + "results": "Output summaries, key findings", +} + + +def test_create_selects_backends_and_defaults_local(): + assert isinstance(Judge.create("local"), LocalJudge) + assert isinstance(Judge.create("api"), APIJudge) + assert isinstance(Judge.create(""), LocalJudge) # default + with pytest.raises(ValueError): + Judge.create("nonesuch") + + +def test_local_judge_constructs_without_loading_weights(): + # No I/O at construction — the GGUF runtime loads lazily on first distill. + j = LocalJudge() + assert j.backend == "local" + assert j._llm is None + assert j.distill([], TAGS) == [] # no-op for now + + +def test_build_prompt_lists_tags_and_renders_turn(): + exchanges = [ + { + "new_messages": [ + {"role": "user", "content": [{"type": "text", "text": "filter reads"}]} + ], + "response": [{"type": "text", "text": "done, 90% passed QC"}], + } + ] + prompt = build_distill_prompt(exchanges, TAGS) + assert "performance" in prompt and "results" in prompt + assert "[user] filter reads" in prompt + assert "[assistant] done, 90% passed QC" in prompt + assert "[]" in prompt # the empty escape is advertised + + +def test_parse_validates_tags_and_strips_fences(): + raw = '```json\n[{"fact": "ran in 3s", "tag": "performance"}]\n```' + assert parse_distill_response(raw, TAGS) == [ + {"text": "ran in 3s", "tag": "performance"} + ] + + +def test_parse_drops_off_taxonomy_and_malformed(): + raw = ( + '[{"fact": "x", "tag": "made_up"}, ' # off-set tag → dropped + '{"fact": "", "tag": "results"}, ' # empty fact → dropped + '"not a dict", ' # junk → dropped + '{"fact": "kept", "tag": "results"}]' + ) + assert parse_distill_response(raw, TAGS) == [{"text": "kept", "tag": "results"}] + + +def test_parse_empty_escape(): + assert parse_distill_response("[]", TAGS) == [] + assert parse_distill_response("", TAGS) == [] + + +@pytest.mark.integration +def test_local_judge_distills_real_model(): + """End-to-end LocalJudge over the real GGUF (downloads ~1GB on first run). + + Deselected by default (``-m 'not integration'``); exercises grammar- + constrained inference: a fact-bearing turn yields well-formed, closed-set + facts, and a trivial turn hits the empty escape. + """ + from dsagt.memory import STOCK_CATEGORIES + + judge = LocalJudge() + fact_turn = [ + { + "new_messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Run fastp at a Q30 quality threshold.", + } + ], + } + ], + "response": [ + { + "type": "text", + "text": "Done — fastp filtered at Q30; 92% of reads passed.", + } + ], + } + ] + facts = judge.distill(fact_turn, STOCK_CATEGORIES) + assert isinstance(facts, list) and facts # deterministic (temp 0): non-empty + for f in facts: + assert set(f) == {"text", "tag"} + assert f["tag"] in STOCK_CATEGORIES # grammar-enforced closed set + assert f["text"].strip() + + trivial = [ + { + "new_messages": [ + {"role": "user", "content": [{"type": "text", "text": "thanks!"}]} + ], + "response": [{"type": "text", "text": "You're welcome."}], + } + ] + assert judge.distill(trivial, STOCK_CATEGORIES) == [] diff --git a/tests/test_kb_search_filters.py b/tests/test_kb_search_filters.py index 23aedb7..774604e 100644 --- a/tests/test_kb_search_filters.py +++ b/tests/test_kb_search_filters.py @@ -47,33 +47,45 @@ def server(mock_kb): # Handler: filter threading # --------------------------------------------------------------------------- + class TestSearchFilterThreading: def test_no_filters_no_where(self, server, mock_kb): - """Search without filter params doesn't pass where to kb.search.""" - call_tool(server, "kb_search", { - "query": "test", - "collection": "docs", - }) + """Search without filter params passes where=None to kb.search.""" + call_tool( + server, + "kb_search", + { + "query": "test", + "collection": "docs", + }, + ) mock_kb.search.assert_called_once_with( query="test", collection="docs", + collections=None, top_k=5, rerank=None, + where=None, ) def test_tool_name_filter_passed(self, server, mock_kb): """tool_name filter is threaded through as where clause.""" - call_tool(server, "kb_search", { - "query": "quality filtering", - "collection": "tool_executions", - "tool_name": "fastp", - }) + call_tool( + server, + "kb_search", + { + "query": "quality filtering", + "collection": "tool_executions", + "tool_name": "fastp", + }, + ) mock_kb.search.assert_called_once_with( query="quality filtering", collection="tool_executions", + collections=None, top_k=5, rerank=None, where={"tool_name": "fastp"}, @@ -81,15 +93,20 @@ def test_tool_name_filter_passed(self, server, mock_kb): def test_session_filter_passed(self, server, mock_kb): """session_id filter is threaded through.""" - call_tool(server, "kb_search", { - "query": "pipeline", - "collection": "tool_executions", - "session_id": "s3", - }) + call_tool( + server, + "kb_search", + { + "query": "pipeline", + "collection": "tool_executions", + "session_id": "s3", + }, + ) mock_kb.search.assert_called_once_with( query="pipeline", collection="tool_executions", + collections=None, top_k=5, rerank=None, where={"session_id": "s3"}, @@ -97,12 +114,16 @@ def test_session_filter_passed(self, server, mock_kb): def test_multiple_filters_combined(self, server, mock_kb): """Multiple filters produce a compound $and where clause.""" - call_tool(server, "kb_search", { - "query": "parameters", - "collection": "tool_executions", - "tool_name": "fastp", - "session_id": "s1", - }) + call_tool( + server, + "kb_search", + { + "query": "parameters", + "collection": "tool_executions", + "tool_name": "fastp", + "session_id": "s1", + }, + ) call_kwargs = mock_kb.search.call_args[1] assert "where" in call_kwargs @@ -111,57 +132,78 @@ def test_multiple_filters_combined(self, server, mock_kb): def test_return_code_filter_passed(self, server, mock_kb): """return_code filter is threaded as integer.""" - call_tool(server, "kb_search", { - "query": "failures", - "collection": "tool_executions", - "return_code": 1, - }) + call_tool( + server, + "kb_search", + { + "query": "failures", + "collection": "tool_executions", + "return_code": 1, + }, + ) mock_kb.search.assert_called_once_with( query="failures", collection="tool_executions", + collections=None, top_k=5, rerank=None, where={"return_code": 1}, ) def test_filters_with_multi_collection(self, server, mock_kb): - """Filters apply to each collection in a multi-collection search.""" + """Multi-collection search delegates to kb.search once with collections=.""" mock_kb.search.return_value = [ make_search_result("result", "/f.md"), ] - call_tool(server, "kb_search", { - "query": "test", - "collections": ["tool_executions", "episodic_memory"], - "tool_name": "fastp", - }) + call_tool( + server, + "kb_search", + { + "query": "test", + "collections": ["tool_executions", "episodic_memory"], + "tool_name": "fastp", + }, + ) - assert mock_kb.search.call_count == 2 - for call in mock_kb.search.call_args_list: - assert call[1]["where"] == {"tool_name": "fastp"} + # Fan-out lives in kb.search now; the handler makes a single call. + mock_kb.search.assert_called_once_with( + query="test", + collection=None, + collections=["tool_executions", "episodic_memory"], + top_k=5, + rerank=None, + where={"tool_name": "fastp"}, + ) # --------------------------------------------------------------------------- # Handler: metadata in results # --------------------------------------------------------------------------- + class TestSearchResultMetadata: def test_metadata_included_in_results(self, mock_kb): """Search results include extra metadata fields.""" mock_kb.search.return_value = [ make_search_result( - "fastp ran", "/file.md", + "fastp ran", + "/file.md", extra_meta={"tool_name": "fastp", "session_id": "s1", "return_code": 0}, ), ] server = create_knowledge_server(mock_kb) - result = call_tool(server, "kb_search", { - "query": "test", - "collection": "tool_executions", - }) + result = call_tool( + server, + "kb_search", + { + "query": "test", + "collection": "tool_executions", + }, + ) hit = result["results"][0] assert hit["metadata"]["tool_name"] == "fastp" @@ -176,10 +218,14 @@ def test_metadata_excludes_standard_fields(self, mock_kb): ] server = create_knowledge_server(mock_kb) - result = call_tool(server, "kb_search", { - "query": "test", - "collection": "docs", - }) + result = call_tool( + server, + "kb_search", + { + "query": "test", + "collection": "docs", + }, + ) meta = result["results"][0]["metadata"] assert "source_file" not in meta @@ -194,10 +240,14 @@ def test_empty_metadata_for_reference_collections(self, mock_kb): ] server = create_knowledge_server(mock_kb) - result = call_tool(server, "kb_search", { - "query": "test", - "collection": "docs", - }) + result = call_tool( + server, + "kb_search", + { + "query": "test", + "collection": "docs", + }, + ) assert result["results"][0]["metadata"] == {} @@ -206,6 +256,7 @@ def test_empty_metadata_for_reference_collections(self, mock_kb): # Schema: filter params visible to agent # --------------------------------------------------------------------------- + class TestSearchSchemaFilters: def _get_kb_search_schema(self, server): @@ -221,7 +272,13 @@ def test_filter_params_in_schema(self, server): """All filter parameters are advertised in the kb_search schema.""" schema = self._get_kb_search_schema(server) props = schema["properties"] - for param in ("category", "session_id", "tool_name", "source_type", "return_code"): + for param in ( + "category", + "session_id", + "tool_name", + "source_type", + "return_code", + ): assert param in props, f"Missing filter param: {param}" def test_filter_params_not_required(self, server): @@ -234,6 +291,7 @@ def test_filter_params_not_required(self, server): # Handler: error behavior for missing collections # --------------------------------------------------------------------------- + class TestSearchCollectionErrors: def test_single_missing_collection_returns_error(self, mock_kb): @@ -241,55 +299,51 @@ def test_single_missing_collection_returns_error(self, mock_kb): mock_kb.search.side_effect = ValueError("Collection 'missing' not found") server = create_knowledge_server(mock_kb) - result = call_tool(server, "kb_search", { - "query": "test", - "collection": "missing", - }) + result = call_tool( + server, + "kb_search", + { + "query": "test", + "collection": "missing", + }, + ) assert result["status"] == "error" assert "not found" in result["error"] - def test_all_multi_collections_missing_returns_error(self, mock_kb): - """When every collection in a multi-search fails, return error.""" - mock_kb.search.side_effect = ValueError("not found") + def test_all_collections_missing_passes_through_error(self, mock_kb): + """kb.search owns the all-failed aggregation; the handler passes it through.""" + mock_kb.search.side_effect = ValueError( + "All collections failed: Collection 'missing1' not found" + ) server = create_knowledge_server(mock_kb) - result = call_tool(server, "kb_search", { - "query": "test", - "collections": ["missing1", "missing2"], - }) + result = call_tool( + server, + "kb_search", + { + "query": "test", + "collections": ["missing1", "missing2"], + }, + ) assert result["status"] == "error" assert "All collections failed" in result["error"] - def test_partial_multi_collection_returns_ok_with_warnings(self, mock_kb): - """When some collections fail, return ok with results and warnings.""" - def search_with_partial_error(**kwargs): - if kwargs["collection"] == "missing": - raise ValueError("Collection 'missing' not found") - return [make_search_result("found it", "/file.md")] - - mock_kb.search.side_effect = search_with_partial_error + def test_partial_multi_collection_returns_ok(self, mock_kb): + """Partial-skip happens inside kb.search; the handler just returns its + (already-fused) results as ok.""" + mock_kb.search.return_value = [make_search_result("found it", "/file.md")] server = create_knowledge_server(mock_kb) - result = call_tool(server, "kb_search", { - "query": "test", - "collections": ["docs", "missing"], - }) + result = call_tool( + server, + "kb_search", + { + "query": "test", + "collections": ["docs", "missing"], + }, + ) assert result["status"] == "ok" assert result["result_count"] == 1 - assert "warnings" in result - assert any("not found" in w for w in result["warnings"]) - - def test_successful_search_has_no_warnings(self, mock_kb): - """Successful search doesn't include a warnings field.""" - server = create_knowledge_server(mock_kb) - - result = call_tool(server, "kb_search", { - "query": "test", - "collection": "docs", - }) - - assert result["status"] == "ok" - assert "warnings" not in result diff --git a/tests/test_knowledge_base.py b/tests/test_knowledge_base.py index 6530dfc..846fd2a 100644 --- a/tests/test_knowledge_base.py +++ b/tests/test_knowledge_base.py @@ -1,9 +1,9 @@ """ -Tests for KnowledgeBase and APIEmbeddingClient. +Tests for KnowledgeBase and APIEmbedder. -APIEmbeddingClient tests mock litellm.embedding to avoid network calls. -KnowledgeBase tests mock _make_embedder with deterministic vectors -and use real FAISS indexes and llama-index chunking on temp files. +APIEmbedder tests mock the client's httpx POST to avoid network +calls. KnowledgeBase tests mock Embedder.create with deterministic vectors +and use real ChromaDB indexes and llama-index chunking on temp files. Reranking is mocked since sentence-transformers is a heavy dependency. """ @@ -13,11 +13,11 @@ from pathlib import Path from unittest.mock import patch, MagicMock -import faiss +import httpx import numpy as np import pytest -from dsagt.knowledge import APIEmbeddingClient, KnowledgeBase, CODE_LANGUAGES +from dsagt.knowledge import APIEmbedder, KnowledgeBase, CODE_LANGUAGES @pytest.fixture(autouse=True) @@ -48,21 +48,23 @@ def fake_embed(texts: list[str]) -> np.ndarray: return np.array(embeddings, dtype=np.float32) -def make_mock_litellm_response(texts: list[str], dim: int = EMBEDDING_DIM): - """Create a fake litellm.EmbeddingResponse for the given texts. +def make_http_response(texts: list[str], dim: int = EMBEDDING_DIM, status: int = 200): + """Build a real ``httpx.Response`` mimicking an OpenAI ``/v1/embeddings`` + reply for the given texts. - LiteLLM returns a Pydantic model whose ``.data`` field is a list of dicts - with ``index`` and ``embedding`` keys (matching the OpenAI API shape). - A MagicMock with the same attribute access is sufficient for tests. + Returning a real ``httpx.Response`` (not a MagicMock) lets the client's + own ``raise_for_status()`` / ``json()`` calls run for real, so tests + exercise the actual parsing + error-classification paths. """ rng = np.random.RandomState(0) data = [ - {"index": i, "embedding": rng.randn(dim).tolist()} - for i in range(len(texts)) + {"index": i, "embedding": rng.randn(dim).tolist()} for i in range(len(texts)) ] - resp = MagicMock() - resp.data = data - return resp + return httpx.Response( + status, + json={"data": data}, + request=httpx.Request("POST", "http://test/embeddings"), + ) def create_test_docs(folder: Path): @@ -90,10 +92,11 @@ def create_test_docs(folder: Path): # --------------------------------------------------------------------------- -# APIEmbeddingClient +# APIEmbedder # --------------------------------------------------------------------------- -class TestAPIEmbeddingClient: + +class TestAPIEmbedder: def test_missing_base_url_raises(self): """Constructor raises ValueError when no base URL is available.""" @@ -102,7 +105,7 @@ def test_missing_base_url_raises(self): env.pop("OPENAI_BASE_URL", None) with patch.dict(os.environ, env, clear=True): with pytest.raises(ValueError, match="base URL required"): - APIEmbeddingClient(api_key="test-key", base_url=None) + APIEmbedder(api_key="test-key", base_url=None) def test_missing_api_key_raises(self): """Constructor raises ValueError when no API key is available.""" @@ -112,342 +115,267 @@ def test_missing_api_key_raises(self): env.pop("OPENAI_API_KEY", None) with patch.dict(os.environ, env, clear=True): with pytest.raises(ValueError, match="API key required"): - APIEmbeddingClient(api_key=None, base_url="http://test") + APIEmbedder(api_key=None, base_url="http://test") def test_explicit_api_key(self): """Constructor accepts an explicit API key.""" - client = APIEmbeddingClient(api_key="explicit-key", base_url="http://test") + client = APIEmbedder(api_key="explicit-key", base_url="http://test") assert client.api_key == "explicit-key" client.close() - def test_bare_model_name_gets_openai_like_prefix(self): - """Bare model names should be routed via the openai_like/ prefix. - - ``openai_like`` is required (not ``openai``) so LiteLLM does not - normalize lab-specific suffixes like ``-project`` away — see the - comment in APIEmbeddingClient.__init__ for the full rationale. - """ - client = APIEmbeddingClient( - api_key="k", base_url="http://test", model="my-embed", - ) - assert client._litellm_model == "openai_like/my-embed" - client.close() - - def test_lab_suffixed_model_name_round_trips_verbatim(self): - """Regression: ``text-embedding-3-small-project`` must NOT be - normalized to ``text-embedding-3-small`` by the openai_like router. + def test_model_name_sent_verbatim(self): + """The model string is sent unchanged — no provider prefix. - Lab LiteLLM proxies route by alias. If the suffix gets stripped, the - request reaches the upstream as a name the proxy's ACL doesn't know - about, and we get a 401 ``team_model_access_denied``. + Gateways route by alias, so lab-specific suffixes + (``text-embedding-3-small-project``) and HuggingFace-style names + with slashes (``lbl/nomic-embed-text``) must reach the endpoint + exactly as configured. """ - client = APIEmbeddingClient( - api_key="k", base_url="http://test", - model="text-embedding-3-small-project", + for name in ( + "my-embed", + "text-embedding-3-small-project", + "lbl/nomic-embed-text", + ): + client = APIEmbedder(api_key="k", base_url="http://test", model=name) + assert client.model == name + client.close() + + def test_embeddings_url_built_from_base_url(self): + """The embeddings route hangs off the (``/v1``) base URL root.""" + client = APIEmbedder( + api_key="k", + base_url="https://gw.example.com/v1/", + model="m", ) - assert client._litellm_model == "openai_like/text-embedding-3-small-project" + assert client._embeddings_url == "https://gw.example.com/v1/embeddings" client.close() - def test_slash_in_model_name_still_gets_openai_like_prefix(self): - """HuggingFace-style names (``lbl/nomic-embed-text``) are model - identifiers, not LiteLLM provider prefixes — the whole thing needs - ``openai_like/`` in front so LiteLLM dispatches to the OpenAI-wire - client pointed at our base_url. The rest of DSAGT assumes an - OpenAI-compat endpoint, so there's no valid case for the user's - string to carry a LiteLLM provider prefix of its own. - """ - client = APIEmbeddingClient( - api_key="k", base_url="http://test", model="lbl/nomic-embed-text", - ) - assert client._litellm_model == "openai_like/lbl/nomic-embed-text" - client.close() - - def test_embed_empty_list(self): - """Embedding an empty list returns an empty array.""" - client = APIEmbeddingClient(api_key="test-key", base_url="http://test") - result = client.embed([]) - assert result.shape == (0,) - assert result.dtype == np.float32 - client.close() - - @patch("litellm.embedding") - def test_embed_calls_litellm_with_correct_args(self, mock_embedding): - """litellm.embedding receives the right model, input, api_base, api_key.""" - mock_embedding.return_value = make_mock_litellm_response(["hello"]) - - client = APIEmbeddingClient( + def test_embed_posts_correct_request(self): + """The POST carries the right URL, model/input body, and auth header.""" + client = APIEmbedder( api_key="my-key", model="test-model", - base_url="https://example.com", + base_url="https://example.com/v1", ) - result = client.embed(["hello"]) + with patch.object( + client._client, + "post", + return_value=make_http_response(["hello"]), + ) as mock_post: + result = client.embed(["hello"]) assert result.shape == (1, EMBEDDING_DIM) - assert mock_embedding.call_count == 1 - call_kwargs = mock_embedding.call_args.kwargs - assert call_kwargs["model"] == "openai_like/test-model" - assert call_kwargs["input"] == ["hello"] - assert call_kwargs["api_base"] == "https://example.com" - assert call_kwargs["api_key"] == "my-key" + assert mock_post.call_count == 1 + url = mock_post.call_args.args[0] + kwargs = mock_post.call_args.kwargs + assert url == "https://example.com/v1/embeddings" + assert kwargs["json"] == {"model": "test-model", "input": ["hello"]} + assert kwargs["headers"]["Authorization"] == "Bearer my-key" client.close() - @patch("litellm.embedding") - def test_embed_returns_vectors_in_index_order(self, mock_embedding): + def test_embed_returns_vectors_in_index_order(self): """Out-of-order response data is sorted back to input order.""" - # Construct a response where data is in reverse order to ensure - # the client sorts by 'index' field. rng = np.random.RandomState(7) out_of_order = [ {"index": 1, "embedding": rng.randn(EMBEDDING_DIM).tolist()}, {"index": 0, "embedding": rng.randn(EMBEDDING_DIM).tolist()}, ] - resp = MagicMock() - resp.data = out_of_order - mock_embedding.return_value = resp + resp = httpx.Response( + 200, + json={"data": out_of_order}, + request=httpx.Request("POST", "http://test/embeddings"), + ) - client = APIEmbeddingClient(api_key="k", base_url="http://test") - result = client.embed(["first", "second"]) + client = APIEmbedder(api_key="k", base_url="http://test") + with patch.object(client._client, "post", return_value=resp): + result = client.embed(["first", "second"]) assert result.shape == (2, EMBEDDING_DIM) # First-row vector matches the data entry with index=0 (the second list element) - assert np.allclose(result[0], np.array(out_of_order[1]["embedding"], dtype=np.float32)) - client.close() - - @patch("litellm.embedding") - def test_embed_handles_pydantic_style_data(self, mock_embedding): - """LiteLLM may return Pydantic objects with attribute access instead of dicts.""" - - class _Item: - def __init__(self, idx, vec): - self.index = idx - self.embedding = vec - - rng = np.random.RandomState(0) - items = [_Item(i, rng.randn(EMBEDDING_DIM).tolist()) for i in range(3)] - resp = MagicMock() - resp.data = items - mock_embedding.return_value = resp - - client = APIEmbeddingClient(api_key="k", base_url="http://test") - result = client.embed(["a", "b", "c"]) - assert result.shape == (3, EMBEDDING_DIM) + assert np.allclose( + result[0], np.array(out_of_order[1]["embedding"], dtype=np.float32) + ) client.close() # --------------------------------------------------------------------------- -# APIEmbeddingClient - retry and error propagation +# APIEmbedder - retry and error propagation # --------------------------------------------------------------------------- -class TestAPIEmbeddingClientErrors: - """Verify the explicit rate-limit retry layer in APIEmbeddingClient. - The retry layer exists because lab LiteLLM proxies wrap upstream 429s - in a way that defeats litellm's built-in retry classification, and - litellm's default backoff is too short for Azure-style 60s quota - windows. These tests pin the contract: +class TestAPIEmbedderErrors: + """Verify the explicit rate-limit retry layer in APIEmbedder. + + The retry layer exists because lab gateways enforce Azure-style 60s + quota windows that a generic exponential backoff would undershoot. + These tests pin the contract: * Authentication / bad-request errors are NOT retried (fail fast on misconfiguration). * Rate-limit and transient errors ARE retried up to max_attempts. - * The upstream "retry after N seconds" hint is honored. + * The ``Retry-After`` header / body hint is honored. """ - @patch("dsagt.knowledge.time.sleep") - @patch("litellm.embedding") - def test_authentication_error_propagates_immediately( - self, mock_embedding, mock_sleep, - ): - """A 401 must NOT be retried — this is a misconfiguration, not transient.""" - import litellm - - mock_embedding.side_effect = litellm.exceptions.AuthenticationError( - message="Invalid API key", llm_provider="openai", model="test", + def _err_response(self, status, headers=None, json_body=None): + return httpx.Response( + status, + headers=headers or {}, + json=json_body if json_body is not None else {"error": "x"}, + request=httpx.Request("POST", "http://test/embeddings"), ) - client = APIEmbeddingClient(api_key="bad-key", base_url="http://test") - with pytest.raises(litellm.exceptions.AuthenticationError): - client.embed(["test"]) + @patch("dsagt.knowledge.time.sleep") + def test_authentication_error_propagates_immediately(self, mock_sleep): + """A 401 must NOT be retried — this is a misconfiguration, not transient.""" + client = APIEmbedder(api_key="bad-key", base_url="http://test") + with patch.object( + client._client, + "post", + return_value=self._err_response(401), + ) as mock_post: + with pytest.raises(httpx.HTTPStatusError): + client.embed(["test"]) # No retries: one call, no sleeps. - assert mock_embedding.call_count == 1 + assert mock_post.call_count == 1 assert mock_sleep.call_count == 0 client.close() @patch("dsagt.knowledge.time.sleep") - @patch("litellm.embedding") - def test_rate_limit_retries_then_propagates(self, mock_embedding, mock_sleep): + def test_rate_limit_retries_then_propagates(self, mock_sleep): """A persistent rate limit retries up to max_attempts then raises.""" - import litellm - - mock_embedding.side_effect = litellm.exceptions.RateLimitError( - message="429 Please retry after 60 seconds", - llm_provider="openai", model="test", - ) - - client = APIEmbeddingClient(api_key="k", base_url="http://test") - with pytest.raises(litellm.exceptions.RateLimitError): - client.embed(["test"]) + resp = self._err_response(429, headers={"retry-after": "60"}) + client = APIEmbedder(api_key="k", base_url="http://test") + with patch.object(client._client, "post", return_value=resp) as mock_post: + with pytest.raises(httpx.HTTPStatusError): + client.embed(["test"]) # max_attempts is 6 — that's 6 calls and 5 sleeps between them. - assert mock_embedding.call_count == 6 + assert mock_post.call_count == 6 assert mock_sleep.call_count == 5 - # Each sleep should respect the upstream-suggested 60s wait. + # Each sleep should respect the Retry-After 60s hint. for call in mock_sleep.call_args_list: assert call.args[0] == 60.0 client.close() @patch("dsagt.knowledge.time.sleep") - @patch("litellm.embedding") - def test_rate_limit_retries_then_succeeds(self, mock_embedding, mock_sleep): + def test_rate_limit_retries_then_succeeds(self, mock_sleep): """If the rate limit clears on a retry, embed() returns successfully.""" - import litellm - - rng = np.random.RandomState(42) - success_resp = MagicMock() - success_resp.data = [ - {"index": 0, "embedding": rng.randn(EMBEDDING_DIM).tolist()}, - ] - + client = APIEmbedder(api_key="k", base_url="http://test") # Two rate-limit failures, then success. - mock_embedding.side_effect = [ - litellm.exceptions.RateLimitError( - message="429 Please retry after 60 seconds", - llm_provider="openai", model="test", - ), - litellm.exceptions.RateLimitError( - message="429 Please retry after 60 seconds", - llm_provider="openai", model="test", - ), - success_resp, + side = [ + self._err_response(429, headers={"retry-after": "60"}), + self._err_response(429, headers={"retry-after": "60"}), + make_http_response(["one"]), ] - - client = APIEmbeddingClient(api_key="k", base_url="http://test") - result = client.embed(["one"]) + with patch.object(client._client, "post", side_effect=side) as mock_post: + result = client.embed(["one"]) assert result.shape == (1, EMBEDDING_DIM) - assert mock_embedding.call_count == 3 + assert mock_post.call_count == 3 assert mock_sleep.call_count == 2 client.close() @patch("dsagt.knowledge.time.sleep") - @patch("litellm.embedding") - def test_transient_connection_error_retries(self, mock_embedding, mock_sleep): - """APIConnectionError is retryable (covers the lab-proxy 429 wrapping case).""" - import litellm - - mock_embedding.side_effect = litellm.exceptions.APIConnectionError( - message="connection reset", - llm_provider="openai_like", model="test", + def test_transient_connection_error_retries(self, mock_sleep): + """Transport errors (connection reset) are retryable.""" + client = APIEmbedder(api_key="k", base_url="http://test") + err = httpx.ConnectError( + "connection reset", + request=httpx.Request("POST", "http://test/embeddings"), ) - - client = APIEmbeddingClient(api_key="k", base_url="http://test") - with pytest.raises(litellm.exceptions.APIConnectionError): - client.embed(["test"]) - assert mock_embedding.call_count == 6 + with patch.object(client._client, "post", side_effect=err) as mock_post: + with pytest.raises(httpx.ConnectError): + client.embed(["test"]) + assert mock_post.call_count == 6 client.close() @patch("dsagt.knowledge.time.sleep") - @patch("litellm.embedding") - def test_lab_proxy_wrapped_429_retries(self, mock_embedding, mock_sleep): - """Real-world failure mode: openai_like wraps an upstream 429 as - APIConnectionError with the rate-limit body in the message. Our - retry layer must detect this via string matching, not just by - exception class, and must use the upstream-suggested wait time. - """ - import litellm - - # This is a lightly-paraphrased version of the actual lab error. - wrapped_429 = litellm.exceptions.APIConnectionError( - message=( - "Openai_likeException - " - '{"error":{"message":"litellm.RateLimitError: AzureException ' - "RateLimitError - rate limit exceeded. " - "Please retry after 90 seconds. " - 'To increase your default rate limit, visit ..."}}' - ), - llm_provider="openai_like", model="text-embedding-3-small-project", - ) - mock_embedding.side_effect = wrapped_429 - - client = APIEmbeddingClient(api_key="k", base_url="http://test") - with pytest.raises(litellm.exceptions.APIConnectionError): - client.embed(["test"]) - - assert mock_embedding.call_count == 6 - # The 90s hint from the upstream message must be honored, not the 60s default. + def test_rate_limit_body_hint_honored_without_header(self, mock_sleep): + """When there's no Retry-After header, a 429 body hint is parsed.""" + body = { + "error": { + "message": ( + "rate limit exceeded. Please retry after 90 seconds. " + "To increase your default rate limit, visit ..." + ) + } + } + resp = self._err_response(429, json_body=body) + client = APIEmbedder(api_key="k", base_url="http://test") + with patch.object(client._client, "post", return_value=resp) as mock_post: + with pytest.raises(httpx.HTTPStatusError): + client.embed(["test"]) + + assert mock_post.call_count == 6 + # The 90s hint from the body must be honored, not the 60s default. for call in mock_sleep.call_args_list: assert call.args[0] == 90.0 client.close() @patch("dsagt.knowledge.time.sleep") - @patch("litellm.embedding") - def test_timeout_retries(self, mock_embedding, mock_sleep): + def test_timeout_retries(self, mock_sleep): """Timeouts are transient and should be retried with exponential backoff.""" - import litellm - - mock_embedding.side_effect = litellm.exceptions.Timeout( - message="Request timed out", llm_provider="openai", model="test", + client = APIEmbedder(api_key="k", base_url="http://test") + err = httpx.ReadTimeout( + "Request timed out", + request=httpx.Request("POST", "http://test/embeddings"), ) - - client = APIEmbeddingClient(api_key="k", base_url="http://test") - with pytest.raises(litellm.exceptions.Timeout): - client.embed(["test"]) - assert mock_embedding.call_count == 6 + with patch.object(client._client, "post", side_effect=err) as mock_post: + with pytest.raises(httpx.ReadTimeout): + client.embed(["test"]) + assert mock_post.call_count == 6 # Exponential backoff capped at 30s: 2^1, 2^2, 2^3, 2^4, min(2^5, 30). sleeps = [c.args[0] for c in mock_sleep.call_args_list] assert sleeps == [2.0, 4.0, 8.0, 16.0, 30.0] client.close() -class TestAPIEmbeddingClientBatching: +class TestAPIEmbedderBatching: """Long inputs are split into batch_size chunks for the embedding API.""" - @patch("litellm.embedding") - def test_batches_when_over_batch_size(self, mock_embedding): + def test_batches_when_over_batch_size(self): """A 250-text input with batch_size=100 produces 3 API calls.""" - rng = np.random.RandomState(0) - - def make_response(input_texts, **_): - resp = MagicMock() - resp.data = [ - {"index": i, "embedding": rng.randn(EMBEDDING_DIM).tolist()} - for i in range(len(input_texts)) - ] - return resp + client = APIEmbedder( + api_key="k", + base_url="http://test", + batch_size=100, + ) - mock_embedding.side_effect = lambda **kwargs: make_response(kwargs["input"]) + def make_response(url, *, json, headers): + return make_http_response(json["input"]) - client = APIEmbeddingClient( - api_key="k", base_url="http://test", batch_size=100, - ) - texts = [f"chunk {i}" for i in range(250)] - result = client.embed(texts) + with patch.object( + client._client, + "post", + side_effect=make_response, + ) as mock_post: + texts = [f"chunk {i}" for i in range(250)] + result = client.embed(texts) assert result.shape == (250, EMBEDDING_DIM) - assert mock_embedding.call_count == 3 + assert mock_post.call_count == 3 # Verify the batch sizes were 100, 100, 50. batch_sizes = [ - len(call.kwargs["input"]) for call in mock_embedding.call_args_list + len(call.kwargs["json"]["input"]) for call in mock_post.call_args_list ] assert batch_sizes == [100, 100, 50] client.close() - @patch("litellm.embedding") - def test_single_call_when_under_batch_size(self, mock_embedding): + def test_single_call_when_under_batch_size(self): """Inputs within batch_size make a single call (no batching loop).""" - rng = np.random.RandomState(0) - resp = MagicMock() - resp.data = [ - {"index": i, "embedding": rng.randn(EMBEDDING_DIM).tolist()} - for i in range(5) - ] - mock_embedding.return_value = resp - - client = APIEmbeddingClient( - api_key="k", base_url="http://test", batch_size=100, + client = APIEmbedder( + api_key="k", + base_url="http://test", + batch_size=100, ) - result = client.embed(["a", "b", "c", "d", "e"]) + with patch.object( + client._client, + "post", + return_value=make_http_response(["a", "b", "c", "d", "e"]), + ) as mock_post: + result = client.embed(["a", "b", "c", "d", "e"]) assert result.shape == (5, EMBEDDING_DIM) - assert mock_embedding.call_count == 1 + assert mock_post.call_count == 1 client.close() @@ -456,23 +384,28 @@ class TestRetryAfterParsing: def test_extracts_seconds_from_message(self): from dsagt.knowledge import _extract_retry_after_seconds + msg = "Please retry after 60 seconds" assert _extract_retry_after_seconds(msg) == 60.0 def test_extracts_seconds_with_decimal(self): from dsagt.knowledge import _extract_retry_after_seconds + assert _extract_retry_after_seconds("retry after 12.5 seconds") == 12.5 def test_case_insensitive(self): from dsagt.knowledge import _extract_retry_after_seconds + assert _extract_retry_after_seconds("RETRY AFTER 30 SECONDS") == 30.0 def test_returns_default_when_no_hint(self): from dsagt.knowledge import _extract_retry_after_seconds + assert _extract_retry_after_seconds("rate limit", default=42.0) == 42.0 def test_finds_hint_in_long_message(self): from dsagt.knowledge import _extract_retry_after_seconds + # The real lab error nests the hint deep inside a JSON body. msg = ( 'Openai_likeException - {"error":{"message":' @@ -486,6 +419,7 @@ def test_finds_hint_in_long_message(self): # KnowledgeBase.ingest exclude_patterns # --------------------------------------------------------------------------- + class TestIngestExcludePatterns: """The exclude_patterns parameter on ingest() filters out files whose relative path matches any of the supplied glob patterns. Used by @@ -498,7 +432,7 @@ def kb(self, tmp_path): index_dir = tmp_path / "index" mock_client = MagicMock() mock_client.embed = fake_embed - with patch("dsagt.knowledge._make_embedder", return_value=mock_client): + with patch("dsagt.knowledge.Embedder.create", return_value=mock_client): kb = KnowledgeBase(index_dir=index_dir) yield kb kb.close() @@ -513,27 +447,27 @@ def repo_layout(self, tmp_path): # Public source (root / "mylib").mkdir() (root / "mylib" / "__init__.py").write_text('"""Mylib package."""\n') - (root / "mylib" / "core.py").write_text('def public_fn():\n return 1\n') - (root / "mylib" / "_internal.py").write_text('def _hidden():\n return 2\n') + (root / "mylib" / "core.py").write_text("def public_fn():\n return 1\n") + (root / "mylib" / "_internal.py").write_text("def _hidden():\n return 2\n") # Tests in a subdirectory (root / "mylib" / "tests").mkdir() (root / "mylib" / "tests" / "__init__.py").write_text("") (root / "mylib" / "tests" / "test_core.py").write_text( - 'def test_public_fn():\n assert True\n' + "def test_public_fn():\n assert True\n" ) # Top-level tests dir as well (root / "tests").mkdir() (root / "tests" / "test_integration.py").write_text( - 'def test_smoke():\n assert True\n' + "def test_smoke():\n assert True\n" ) (root / "tests" / "conftest.py").write_text("import pytest\n") # Examples (kept on purpose for the agent) (root / "examples").mkdir() (root / "examples" / "quickstart.py").write_text( - 'from mylib import public_fn\nprint(public_fn())\n' + "from mylib import public_fn\nprint(public_fn())\n" ) # Docs @@ -548,7 +482,8 @@ def repo_layout(self, tmp_path): def test_no_exclude_keeps_everything(self, kb, repo_layout): result = kb.ingest( - repo_layout, collection_name="full", + repo_layout, + collection_name="full", file_types=["py", "md"], ) # All py + md files except the .pyc which isn't in file_types. @@ -561,7 +496,8 @@ def test_no_exclude_keeps_everything(self, kb, repo_layout): def test_exclude_tests_directory(self, kb, repo_layout): """Pattern 'tests' should match the tests segment in any path.""" result = kb.ingest( - repo_layout, collection_name="no_tests", + repo_layout, + collection_name="no_tests", file_types=["py", "md"], exclude_patterns=["tests"], ) @@ -574,7 +510,8 @@ def test_exclude_tests_directory(self, kb, repo_layout): def test_exclude_test_files_by_basename(self, kb, repo_layout): """Pattern 'test_*.py' matches the basename anywhere in the tree.""" result = kb.ingest( - repo_layout, collection_name="no_test_files", + repo_layout, + collection_name="no_test_files", file_types=["py", "md"], exclude_patterns=["test_*.py"], ) @@ -591,7 +528,8 @@ def test_exclude_private_modules(self, kb, repo_layout): want to keep __init__.py should use a more specific pattern. """ result = kb.ingest( - repo_layout, collection_name="no_private", + repo_layout, + collection_name="no_private", file_types=["py", "md"], exclude_patterns=["_*.py"], ) @@ -607,11 +545,13 @@ def test_exclude_pycache_dir(self, kb, repo_layout): (repo_layout / "mylib" / "__pycache__" / "fake.py").write_text("x = 1\n") result_unfiltered = kb.ingest( - repo_layout, collection_name="with_cache", + repo_layout, + collection_name="with_cache", file_types=["py"], ) result_filtered = kb.ingest( - repo_layout, collection_name="no_cache", + repo_layout, + collection_name="no_cache", file_types=["py"], exclude_patterns=["__pycache__"], ) @@ -622,7 +562,8 @@ def test_combined_default_patterns(self, kb, repo_layout): from dsagt.commands.setup_core_kb import DEFAULT_EXCLUDE_PATTERNS result = kb.ingest( - repo_layout, collection_name="defaults", + repo_layout, + collection_name="defaults", file_types=["py", "md"], exclude_patterns=DEFAULT_EXCLUDE_PATTERNS, ) @@ -650,7 +591,8 @@ def test_default_patterns_keep_packaging_metadata(self, kb, tmp_path): (root / "mylib" / "core.py").write_text("def f():\n return 1\n") result = kb.ingest( - root, collection_name="pkg_meta", + root, + collection_name="pkg_meta", file_types=["py", "toml", "cfg"], exclude_patterns=DEFAULT_EXCLUDE_PATTERNS, ) @@ -665,7 +607,7 @@ class TestCollectFilesDirectly: The whole point of pulling this helper out of ingest() was to make file-discovery and exclude-pattern logic testable WITHOUT spinning up an embedder, an index, or a chunker. These tests exercise the helper - directly: no mocked _make_embedder context, no add_entries call, no + directly: no mocked Embedder.create context, no add_entries call, no cleanup of cached collections. If a regression in the file-walk or fnmatch logic ever lands, these tests fail in milliseconds and point at the exact problem instead of being buried under ingest() setup. @@ -678,7 +620,7 @@ def kb(self, tmp_path): return KnowledgeBase( index_dir=tmp_path / "index", default_embedder="local", - embedder_kwargs={"model": "unused"}, + model="unused", ) @pytest.fixture @@ -733,6 +675,7 @@ def test_returns_paths_not_strings(self, kb, repo): # KnowledgeBase - collections and ingest # --------------------------------------------------------------------------- + class TestKnowledgeBaseIngest: @pytest.fixture @@ -742,7 +685,7 @@ def kb(self, tmp_path): mock_client = MagicMock() mock_client.embed = fake_embed - with patch("dsagt.knowledge._make_embedder", return_value=mock_client): + with patch("dsagt.knowledge.Embedder.create", return_value=mock_client): kb = KnowledgeBase(index_dir=index_dir) yield kb kb.close() @@ -788,24 +731,6 @@ def test_list_collections_includes_description(self, kb, source_folder): assert collections[0]["name"] == "test_docs" assert "unit tests" in collections[0]["description"] - def test_ingest_creates_faiss_index(self, tmp_path, source_folder): - """Ingest into an explicitly FAISS-routed KB produces an index.faiss.""" - index_dir = tmp_path / "index_faiss" - mock_client = MagicMock() - mock_client.embed = fake_embed - - with patch("dsagt.knowledge._make_embedder", return_value=mock_client): - kb = KnowledgeBase(index_dir=index_dir, default_index="faiss") - try: - kb.ingest(source_folder) - index_path = kb.index_dir / "test_docs" / "index.faiss" - assert index_path.exists() - - index = faiss.read_index(str(index_path)) - assert index.ntotal > 0 - finally: - kb.close() - def test_ingest_creates_chunks_jsonl(self, kb, source_folder): """Ingest produces a chunks.jsonl with valid entries.""" result = kb.ingest(source_folder) @@ -864,6 +789,7 @@ def test_ingest_no_description(self, kb, tmp_path): # KnowledgeBase - search # --------------------------------------------------------------------------- + class TestKnowledgeBaseSearch: @pytest.fixture @@ -877,7 +803,7 @@ def kb_with_data(self, tmp_path): mock_client = MagicMock() mock_client.embed = fake_embed - with patch("dsagt.knowledge._make_embedder", return_value=mock_client): + with patch("dsagt.knowledge.Embedder.create", return_value=mock_client): kb = KnowledgeBase(index_dir=index_dir) kb.ingest(source_folder) yield kb @@ -925,6 +851,7 @@ def test_search_with_rerank(self, kb_with_data): mock_st.CrossEncoder.return_value = mock_reranker import sys + with patch.dict(sys.modules, {"sentence_transformers": mock_st}): # Ensure the lazy import triggers kb_with_data._reranker = None @@ -957,12 +884,14 @@ def test_search_collection_isolation(self, tmp_path): mock_client = MagicMock() mock_client.embed = fake_embed - with patch("dsagt.knowledge._make_embedder", return_value=mock_client): + with patch("dsagt.knowledge.Embedder.create", return_value=mock_client): kb = KnowledgeBase(index_dir=index_dir) kb.ingest(folder_a) kb.ingest(folder_b) - results = kb.search("rockets", collection="collection_a", top_k=5, rerank=False) + results = kb.search( + "rockets", collection="collection_a", top_k=5, rerank=False + ) sources = [r["chunk"]["metadata"]["collection"] for r in results] assert all(s == "collection_a" for s in sources) @@ -973,6 +902,7 @@ def test_search_collection_isolation(self, tmp_path): # KnowledgeBase - loading and caching # --------------------------------------------------------------------------- + class TestKnowledgeBaseLoad: def test_load_caches_collection(self, tmp_path): @@ -985,19 +915,20 @@ def test_load_caches_collection(self, tmp_path): mock_client = MagicMock() mock_client.embed = fake_embed - with patch("dsagt.knowledge._make_embedder", return_value=mock_client): + with patch("dsagt.knowledge.Embedder.create", return_value=mock_client): kb = KnowledgeBase(index_dir=index_dir) kb.ingest(source_folder) - # Clear the cache that ingest populated - kb._cache.clear() + # Clear the store cache that ingest populated + store = kb._store + store._cache.clear() # First load reads from disk - index1, chunks1 = kb._load("docs") - assert "docs" in kb._cache + index1, chunks1 = store._load("docs") + assert "docs" in store._cache # Second load returns cached - index2, chunks2 = kb._load("docs") + index2, chunks2 = store._load("docs") assert index1 is index2 assert chunks1 is chunks2 @@ -1008,28 +939,32 @@ def test_load_caches_collection(self, tmp_path): # KnowledgeBase - parser selection # --------------------------------------------------------------------------- + class TestGetParser: @pytest.fixture def kb(self, tmp_path): # New __init__ doesn't create an embedder, but mock to be safe - with patch("dsagt.knowledge._make_embedder"): + with patch("dsagt.knowledge.Embedder.create"): kb = KnowledgeBase(index_dir=tmp_path / "index") yield kb kb.close() def test_markdown_parser(self, kb): from llama_index.core.node_parser import MarkdownNodeParser + parser = kb._get_parser(".md") assert isinstance(parser, MarkdownNodeParser) def test_code_parser(self, kb): from llama_index.core.node_parser import CodeSplitter + parser = kb._get_parser(".py") assert isinstance(parser, CodeSplitter) def test_default_parser(self, kb): from llama_index.core.node_parser import SentenceSplitter + parser = kb._get_parser(".txt") assert isinstance(parser, SentenceSplitter) @@ -1044,108 +979,97 @@ def test_code_languages_coverage(self): # KnowledgeBase - context manager # --------------------------------------------------------------------------- + class TestContextManager: def test_context_manager_calls_close(self, tmp_path): """close() cleans up cached embedders created during the session.""" mock_client = MagicMock() - with patch("dsagt.knowledge._make_embedder", return_value=mock_client): + with patch("dsagt.knowledge.Embedder.create", return_value=mock_client): with KnowledgeBase(index_dir=tmp_path / "index") as kb: - # Trigger embedder creation so close() has something to clean up - route = kb._get_route("dummy") - kb._get_embedder(route) + # Trigger lazy embedder construction so close() has something + # to clean up. + kb._store.embedder mock_client.close.assert_called_once() -class TestEmbedderCredentialMerge: - """Routes carry per-collection overrides; credentials live on the kb's - default route. Explicit routes (e.g. EPISODIC_MEMORY_ROUTE) must inherit - api_key/base_url from the default kb config — otherwise memory extraction - falls through to env-var fallbacks and breaks under mixed-endpoint setups - where LLM_API_KEY != EMBEDDING_API_KEY. - """ +class TestStoreEmbedderConstruction: + """One embedder per store, built lazily from explicit args. - def test_explicit_route_inherits_default_credentials(self, tmp_path): - from dsagt.knowledge import CollectionRoute + Per-collection embedder routing was removed: the store fixes a single + embedder at construction, so the explicit args the KB was given flow + straight through to ``Embedder.create`` (named, no kwargs dict) on first use. + """ - with patch("dsagt.knowledge._make_embedder") as mock_make: + def test_store_builds_embedder_from_args(self, tmp_path): + with patch("dsagt.knowledge.Embedder.create") as mock_make: kb = KnowledgeBase( index_dir=tmp_path / "index", - embedder_kwargs={ - "api_key": "sk-real-key", - "base_url": "https://embed.example.com", - "model": "text-embedding-3-small", - }, + default_embedder="api", + api_key="sk-real-key", + base_url="https://embed.example.com", + model="text-embedding-3-small", ) - # Route with no embedder_kwargs (mimics EPISODIC_MEMORY_ROUTE). - route = CollectionRoute(embedding_backend="api", vector_db="chroma") - kb._get_embedder(route) + kb._store.embedder # trigger lazy construction mock_make.assert_called_once_with( "api", - api_key="sk-real-key", - base_url="https://embed.example.com", model="text-embedding-3-small", + base_url="https://embed.example.com", + api_key="sk-real-key", + device=None, ) - def test_route_kwargs_override_default(self, tmp_path): - """Per-route overrides win over kb defaults for matching keys.""" - from dsagt.knowledge import CollectionRoute - - with patch("dsagt.knowledge._make_embedder") as mock_make: + def test_embedder_built_once_and_cached(self, tmp_path): + """Repeated access builds the embedder exactly once.""" + with patch("dsagt.knowledge.Embedder.create") as mock_make: kb = KnowledgeBase( index_dir=tmp_path / "index", - embedder_kwargs={ - "api_key": "sk-real-key", - "base_url": "https://embed.example.com", - "model": "default-model", - }, - ) - # Route overrides model but not credentials. - route = CollectionRoute( - embedding_backend="api", - vector_db="chroma", - embedder_kwargs={"model": "BAAI/bge-base-en-v1.5"}, - ) - kb._get_embedder(route) - - mock_make.assert_called_once_with( - "api", + default_embedder="api", api_key="sk-real-key", base_url="https://embed.example.com", - model="BAAI/bge-base-en-v1.5", + model="default-model", ) + kb._store.embedder + kb._store.embedder + assert mock_make.call_count == 1 # --------------------------------------------------------------------------- # BM25 sparse index + RRF hybrid retrieval # --------------------------------------------------------------------------- + class TestBM25Tokenize: """The tokenizer drives recall — verify identifier-style splits land.""" def test_lowercases(self): from dsagt.knowledge import _bm25_tokenize + assert _bm25_tokenize("Hello WORLD") == ["hello", "world"] def test_splits_on_underscore(self): from dsagt.knowledge import _bm25_tokenize + # snake_case must fan out for BM25 to score "user_id" queries against # surrounding code. assert _bm25_tokenize("get_user_id") == ["get", "user", "id"] def test_splits_on_hyphen(self): from dsagt.knowledge import _bm25_tokenize + assert _bm25_tokenize("kb-ingest") == ["kb", "ingest"] def test_drops_punctuation(self): from dsagt.knowledge import _bm25_tokenize + assert _bm25_tokenize("foo.bar(baz)") == ["foo", "bar", "baz"] def test_keeps_numbers(self): from dsagt.knowledge import _bm25_tokenize + assert _bm25_tokenize("v1.2.3 model") == ["v1", "2", "3", "model"] @@ -1153,12 +1077,15 @@ class TestBM25Index: def test_build_then_search_finds_exact_match(self, tmp_path): from dsagt.knowledge import BM25Index + idx = BM25Index() - idx.build([ - "Alpha rocket fuel composition.", - "Beta submarine pressure hull.", - "Gamma rocket telemetry parser.", - ]) + idx.build( + [ + "Alpha rocket fuel composition.", + "Beta submarine pressure hull.", + "Gamma rocket telemetry parser.", + ] + ) scores, indices = idx.search("rocket", k=3) assert len(indices) == 3 # Docs 0 and 2 mention "rocket", should outrank doc 1. @@ -1167,6 +1094,7 @@ def test_build_then_search_finds_exact_match(self, tmp_path): def test_empty_corpus_returns_empty(self, tmp_path): from dsagt.knowledge import BM25Index + idx = BM25Index() idx.build([]) scores, indices = idx.search("anything", k=5) @@ -1174,6 +1102,7 @@ def test_empty_corpus_returns_empty(self, tmp_path): def test_empty_query_returns_empty(self, tmp_path): from dsagt.knowledge import BM25Index + idx = BM25Index() idx.build(["alpha", "beta"]) # Punctuation-only query tokenizes to []; should not crash. @@ -1182,6 +1111,7 @@ def test_empty_query_returns_empty(self, tmp_path): def test_save_load_roundtrip(self, tmp_path): from dsagt.knowledge import BM25Index + idx = BM25Index() idx.build(["foo bar", "baz qux", "foo qux"]) idx.save(tmp_path) @@ -1195,6 +1125,7 @@ def test_save_load_roundtrip(self, tmp_path): def test_load_missing_file_returns_empty(self, tmp_path): from dsagt.knowledge import BM25Index + loaded = BM25Index.load(tmp_path) assert loaded.size == 0 @@ -1203,11 +1134,13 @@ class TestRRFMerge: def test_single_ranker_passes_through_order(self): from dsagt.knowledge import _rrf_merge + merged = _rrf_merge([[5, 2, 7]]) assert [idx for idx, _ in merged] == [5, 2, 7] def test_two_rankers_promote_shared_top(self): from dsagt.knowledge import _rrf_merge + # Doc 1 is rank-1 in both rankings; doc 2 only in one. RRF must # rank doc 1 above all unique docs. merged = _rrf_merge([[1, 2, 3], [1, 4, 5]]) @@ -1216,12 +1149,71 @@ def test_two_rankers_promote_shared_top(self): def test_skips_negative_indices(self): from dsagt.knowledge import _rrf_merge - # FAISS pads with -1 when fewer than k results; must not pollute scores. + + # Backends pad with -1 when fewer than k results; must not pollute scores. merged = _rrf_merge([[0, -1, -1], [0, 1, 2]]) idxs = [idx for idx, _ in merged] assert -1 not in idxs +class TestFederatedSearch: + """KnowledgeBase.search owns collection→store routing + cross-collection RRF.""" + + @pytest.fixture + def kb(self, tmp_path): + mock_client = MagicMock() + mock_client.embed = fake_embed + with patch("dsagt.knowledge.Embedder.create", return_value=mock_client): + kb = KnowledgeBase(index_dir=tmp_path / "index") + kb.add_entries( + texts=["alpha quality filtering", "alpha assembly step"], + collection="coll_a", + ) + kb.add_entries( + texts=["beta quality filtering", "beta assembly step"], + collection="coll_b", + ) + yield kb + kb.close() + + def test_single_collection_routes_to_store(self, kb): + results = kb.search("quality", collection="coll_a", top_k=5, rerank=False) + assert results + assert all(r["chunk"]["metadata"]["collection"] == "coll_a" for r in results) + + def test_multi_collection_fuses_both(self, kb): + results = kb.search( + "quality filtering", + collections=["coll_a", "coll_b"], + top_k=10, + rerank=False, + ) + seen = {r["chunk"]["metadata"]["collection"] for r in results} + assert seen == {"coll_a", "coll_b"} + # Fused scores are descending RRF scores. + scores = [r["score"] for r in results] + assert scores == sorted(scores, reverse=True) + + def test_missing_single_collection_raises(self, kb): + with pytest.raises(ValueError, match="not found"): + kb.search("x", collection="nope", top_k=5) + + def test_partial_missing_skips_and_returns_found(self, kb): + results = kb.search( + "quality", collections=["coll_a", "nope"], top_k=5, rerank=False + ) + assert results + assert all(r["chunk"]["metadata"]["collection"] == "coll_a" for r in results) + + def test_all_missing_raises_all_failed(self, kb): + with pytest.raises(ValueError, match="All collections failed"): + kb.search("x", collections=["nope1", "nope2"], top_k=5) + + def test_no_target_raises(self, kb): + with pytest.raises(ValueError, match="Provide"): + kb.search("x", top_k=5) + + class TestHybridSearch: """End-to-end hybrid search behavior on a real KnowledgeBase.""" @@ -1235,8 +1227,8 @@ def kb_with_data(self, tmp_path): mock_client = MagicMock() mock_client.embed = fake_embed - with patch("dsagt.knowledge._make_embedder", return_value=mock_client): - kb = KnowledgeBase(index_dir=index_dir, default_index="faiss") + with patch("dsagt.knowledge.Embedder.create", return_value=mock_client): + kb = KnowledgeBase(index_dir=index_dir) kb.ingest(source_folder) yield kb kb.close() @@ -1256,46 +1248,6 @@ def test_search_succeeds_with_hybrid_default(self, kb_with_data): ) assert len(results) > 0 - def test_search_raises_when_hybrid_but_no_bm25(self, tmp_path): - """Hybrid=True with missing bm25.pkl is a loud failure.""" - from dsagt.knowledge import KnowledgeBase, CollectionRoute - - index_dir = tmp_path / "index" - source_folder = tmp_path / "docs" - source_folder.mkdir() - (source_folder / "doc.txt").write_text("Some content here.") - - mock_client = MagicMock() - mock_client.embed = fake_embed - - # Build with hybrid=False so no bm25.pkl is written, then flip the - # persisted route to hybrid=True to simulate a pre-hybrid collection - # in a now-hybrid-aware install. - with patch("dsagt.knowledge._make_embedder", return_value=mock_client): - kb = KnowledgeBase(index_dir=index_dir, default_index="faiss") - kb.ingest( - source_folder, - route=CollectionRoute( - embedding_backend="api", - vector_db="faiss", - hybrid=False, - ), - ) - kb.close() - - # Mutate the persisted route on disk. - import json as _json - route_path = index_dir / "docs" / "route.json" - route_data = _json.loads(route_path.read_text()) - route_data["hybrid"] = True - route_path.write_text(_json.dumps(route_data)) - - # Fresh KB instance: no in-memory cache, must read from disk. - kb2 = KnowledgeBase(index_dir=index_dir, default_index="faiss") - with pytest.raises(FileNotFoundError, match="bm25.pkl"): - kb2.search("anything", collection="docs", top_k=3, rerank=False) - kb2.close() - def test_add_entries_rebuilds_bm25(self, tmp_path): """add_entries must rebuild bm25.pkl with the new entry texts.""" from dsagt.knowledge import KnowledgeBase @@ -1303,8 +1255,8 @@ def test_add_entries_rebuilds_bm25(self, tmp_path): mock_client = MagicMock() mock_client.embed = fake_embed - with patch("dsagt.knowledge._make_embedder", return_value=mock_client): - kb = KnowledgeBase(index_dir=tmp_path / "index", default_index="faiss") + with patch("dsagt.knowledge.Embedder.create", return_value=mock_client): + kb = KnowledgeBase(index_dir=tmp_path / "index") kb.add_entries( texts=["alpha document", "beta document"], collection="memory", @@ -1314,32 +1266,6 @@ def test_add_entries_rebuilds_bm25(self, tmp_path): kb.add_entries(texts=["gamma document"], collection="memory") # BM25 must now know about all three docs. - bm25 = kb._get_bm25("memory") + bm25 = kb._store._get_bm25("memory") assert bm25.size == 3 kb.close() - - def test_route_persists_hybrid_flag(self, tmp_path): - """route.json must round-trip the hybrid field.""" - from dsagt.knowledge import KnowledgeBase, CollectionRoute - import json as _json - - mock_client = MagicMock() - mock_client.embed = fake_embed - - with patch("dsagt.knowledge._make_embedder", return_value=mock_client): - kb = KnowledgeBase(index_dir=tmp_path / "index", default_index="faiss") - kb.add_entries( - texts=["x"], - collection="c", - route=CollectionRoute( - embedding_backend="api", - vector_db="faiss", - hybrid=False, - ), - ) - kb.close() - - route_data = _json.loads( - (tmp_path / "index" / "c" / "route.json").read_text() - ) - assert route_data["hybrid"] is False diff --git a/tests/test_knowledge_integration.py b/tests/test_knowledge_integration.py index ca94a1e..c5b65e0 100755 --- a/tests/test_knowledge_integration.py +++ b/tests/test_knowledge_integration.py @@ -24,7 +24,10 @@ from dsagt.knowledge import KnowledgeBase from dsagt.mcp.knowledge_tools import create_knowledge_server, setup_runtime_kb -from mcp_helpers import call_tool_json as call_tool, call_tool_async as _call_tool_async_raw +from mcp_helpers import ( + call_tool_json as call_tool, + call_tool_async as _call_tool_async_raw, +) async def _call_tool_async(server, name: str, arguments: dict) -> dict: @@ -32,7 +35,9 @@ async def _call_tool_async(server, name: str, arguments: dict) -> dict: return json.loads(await _call_tool_async_raw(server, name, arguments)) -async def call_tool_and_await_job(server, name: str, arguments: dict) -> tuple[dict, dict]: +async def call_tool_and_await_job( + server, name: str, arguments: dict +) -> tuple[dict, dict]: """Call a tool that starts a background job, wait for it, return (initial, final).""" initial = await _call_tool_async(server, name, arguments) assert initial["status"] == "started" @@ -51,6 +56,7 @@ async def call_tool_and_await_job(server, name: str, arguments: dict) -> tuple[d # Fixtures # --------------------------------------------------------------------------- + @pytest.fixture def smoke_test_dir(): """Path to the smoke test knowledge documents.""" @@ -94,6 +100,7 @@ def kb_server(tmp_path, smoke_test_dir, embedding_config): # Ingest via MCP handler # --------------------------------------------------------------------------- + class TestIngestIntegration: def test_ingest_smoke_test_docs(self, tmp_path, smoke_test_dir, embedding_config): @@ -121,14 +128,19 @@ async def run(): # Search via MCP handler # --------------------------------------------------------------------------- + class TestSearchIntegration: def test_search_returns_results(self, kb_server): """Search through MCP handler returns relevant chunks.""" - result = call_tool(kb_server, "kb_search", { - "query": "how to handle large files", - "collection": "knowledge", - }) + result = call_tool( + kb_server, + "kb_search", + { + "query": "how to handle large files", + "collection": "knowledge", + }, + ) assert result["status"] == "ok" assert result["result_count"] > 0 @@ -137,31 +149,43 @@ def test_search_returns_results(self, kb_server): def test_search_nonexistent_collection(self, kb_server): """Searching a nonexistent collection returns an error.""" - result = call_tool(kb_server, "kb_search", { - "query": "anything", - "collection": "nonexistent", - }) + result = call_tool( + kb_server, + "kb_search", + { + "query": "anything", + "collection": "nonexistent", + }, + ) assert result["status"] == "error" def test_search_with_top_k(self, kb_server): """top_k limits the number of results.""" - result = call_tool(kb_server, "kb_search", { - "query": "installation", - "collection": "knowledge", - "top_k": 2, - }) + result = call_tool( + kb_server, + "kb_search", + { + "query": "installation", + "collection": "knowledge", + "top_k": 2, + }, + ) assert result["status"] == "ok" assert result["result_count"] <= 2 def test_search_result_format(self, kb_server): """Each result has expected fields.""" - result = call_tool(kb_server, "kb_search", { - "query": "API reference", - "collection": "knowledge", - "top_k": 1, - }) + result = call_tool( + kb_server, + "kb_search", + { + "query": "API reference", + "collection": "knowledge", + "top_k": 1, + }, + ) assert result["status"] == "ok" if result["result_count"] > 0: @@ -176,6 +200,7 @@ def test_search_result_format(self, kb_server): # List collections via MCP handler # --------------------------------------------------------------------------- + class TestListCollectionsIntegration: def test_list_after_ingest(self, kb_server): @@ -192,9 +217,12 @@ def test_list_after_ingest(self, kb_server): # Setup runtime KB with symlinks # --------------------------------------------------------------------------- + class TestSetupRuntimeKB: - def test_symlinks_base_collections(self, tmp_path, smoke_test_dir, embedding_config): + def test_symlinks_base_collections( + self, tmp_path, smoke_test_dir, embedding_config + ): """setup_runtime_kb symlinks base collections into runtime.""" base_dir = tmp_path / "base_kb" base_dir.mkdir() @@ -211,9 +239,11 @@ def test_symlinks_base_collections(self, tmp_path, smoke_test_dir, embedding_con knowledge_link = runtime_kb_dir / "knowledge" assert knowledge_link.exists() assert knowledge_link.is_symlink() - assert (knowledge_link / "index.faiss").exists() + assert (knowledge_link / "chroma_ids.json").exists() - def test_runtime_search_via_symlink(self, tmp_path, smoke_test_dir, embedding_config): + def test_runtime_search_via_symlink( + self, tmp_path, smoke_test_dir, embedding_config + ): """A KB pointing at runtime symlinks can search successfully.""" base_dir = tmp_path / "base_kb" base_dir.mkdir() diff --git a/tests/test_knowledge_server.py b/tests/test_knowledge_server.py index 3275fbc..cf2d94c 100644 --- a/tests/test_knowledge_server.py +++ b/tests/test_knowledge_server.py @@ -179,8 +179,10 @@ def test_search_passes_parameters(self, server, mock_kb): mock_kb.search.assert_called_once_with( query="test", collection="docs", + collections=None, top_k=10, rerank=False, + where=None, ) def test_search_defaults(self, server, mock_kb): @@ -197,8 +199,10 @@ def test_search_defaults(self, server, mock_kb): mock_kb.search.assert_called_once_with( query="test", collection="docs", + collections=None, top_k=5, rerank=None, # agent didn't specify → kb.default_rerank resolves it + where=None, ) def test_search_nonexistent_collection(self, server, mock_kb): @@ -346,12 +350,12 @@ def test_ingest_deconflicts_existing_collection(self, server, mock_kb, tmp_path) folder = tmp_path / "docs" folder.mkdir() - # Simulate "docs" already exists with a FAISS index from a different source. + # Simulate "docs" already exists with a Chroma index from a different source. # _collection_exists() requires a marker file, and deconflict only triggers # when source.txt records a different folder than the one being ingested. existing = mock_kb.index_dir / "docs" existing.mkdir() - (existing / "index.faiss").write_bytes(b"fake") + (existing / "chroma_ids.json").write_bytes(b"fake") (existing / "source.txt").write_text("/some/other/folder") mock_kb.ingest.return_value = {"collection": "docs1", "files": 3, "chunks": 10} @@ -377,7 +381,7 @@ def test_ingest_deconflicts_symlinked_collection(self, server, mock_kb, tmp_path # Simulate "docs" is a symlink to a base collection with index base_dir = tmp_path / "base_docs" base_dir.mkdir() - (base_dir / "index.faiss").write_bytes(b"fake") + (base_dir / "chroma_ids.json").write_bytes(b"fake") (base_dir / "source.txt").write_text("/some/other/folder") (mock_kb.index_dir / "docs").symlink_to(base_dir) @@ -455,7 +459,7 @@ def test_append_returns_started(self, server, mock_kb, tmp_path): # Create a fake existing collection coll_dir = mock_kb.index_dir / "docs" coll_dir.mkdir(exist_ok=True) - (coll_dir / "index.faiss").write_text("fake") + (coll_dir / "chroma_ids.json").write_text("fake") result = call_tool( server, @@ -474,7 +478,7 @@ def test_append_job_completes(self, server, mock_kb, tmp_path): """Background append job completes successfully.""" coll_dir = mock_kb.index_dir / "docs" coll_dir.mkdir(exist_ok=True) - (coll_dir / "index.faiss").write_text("fake") + (coll_dir / "chroma_ids.json").write_text("fake") async def run(): initial, final = await call_tool_and_await_job( @@ -604,7 +608,7 @@ def test_search_httpx_500(self, mock_kb): def test_search_runtime_error(self, mock_kb): """Unexpected RuntimeError during search returns error, not crash.""" - mock_kb.search.side_effect = RuntimeError("FAISS segfault simulation") + mock_kb.search.side_effect = RuntimeError("index segfault simulation") server = create_knowledge_server(mock_kb) result = call_tool( @@ -617,11 +621,11 @@ def test_search_runtime_error(self, mock_kb): ) assert result["status"] == "error" - assert "FAISS segfault" in result["error"] + assert "index segfault" in result["error"] def test_search_os_error(self, mock_kb): """OS-level error (disk, permissions) returns error, not crash.""" - mock_kb.search.side_effect = OSError("Permission denied: index.faiss") + mock_kb.search.side_effect = OSError("Permission denied: chroma.sqlite3") server = create_knowledge_server(mock_kb) result = call_tool( @@ -656,7 +660,7 @@ def test_copies_collections(self, tmp_path): base = tmp_path / "base_index" coll_dir = base / "my_collection" coll_dir.mkdir(parents=True) - (coll_dir / "index.faiss").write_text("fake index") + (coll_dir / "chroma_ids.json").write_text("fake index") (coll_dir / "chunks.jsonl").write_text('{"id": "1"}\n') (coll_dir / "DESCRIPTION.md").write_text("Test collection") @@ -667,8 +671,8 @@ def test_copies_collections(self, tmp_path): copied = result / "my_collection" assert copied.exists() assert not copied.is_symlink() # copy not symlink - assert (copied / "index.faiss").exists() - assert (copied / "index.faiss").read_text() == "fake index" + assert (copied / "chroma_ids.json").exists() + assert (copied / "chroma_ids.json").read_text() == "fake index" assert (copied / "chunks.jsonl").exists() assert (copied / "DESCRIPTION.md").exists() @@ -677,20 +681,20 @@ def test_copy_is_independent(self, tmp_path): base = tmp_path / "base_index" coll = base / "tools" coll.mkdir(parents=True) - (coll / "index.faiss").write_text("v1") + (coll / "chroma_ids.json").write_text("v1") runtime = tmp_path / "runtime" setup_runtime_kb(base, runtime) # Mutate the base — simulating ``dsagt setup-kb --rebuild``. - (coll / "index.faiss").write_text("v2 newer") + (coll / "chroma_ids.json").write_text("v2 newer") # Project copy stays at v1. - project_copy = runtime / "kb_index" / "tools" / "index.faiss" + project_copy = runtime / "kb_index" / "tools" / "chroma_ids.json" assert project_copy.read_text() == "v1" def test_skips_non_collection_dirs(self, tmp_path): - """Directories without index.faiss are not copied.""" + """Directories without chroma_ids.json are not copied.""" base = tmp_path / "base_index" (base / "random_dir").mkdir(parents=True) (base / "random_dir" / "notes.txt").write_text("not a collection") @@ -713,16 +717,16 @@ def test_does_not_overwrite_existing(self, tmp_path): base = tmp_path / "base_index" coll = base / "docs" coll.mkdir(parents=True) - (coll / "index.faiss").write_text("base version") + (coll / "chroma_ids.json").write_text("base version") runtime = tmp_path / "runtime" runtime_coll = runtime / "kb_index" / "docs" runtime_coll.mkdir(parents=True) - (runtime_coll / "index.faiss").write_text("runtime version") + (runtime_coll / "chroma_ids.json").write_text("runtime version") setup_runtime_kb(base, runtime) - assert (runtime_coll / "index.faiss").read_text() == "runtime version" + assert (runtime_coll / "chroma_ids.json").read_text() == "runtime version" # --------------------------------------------------------------------------- @@ -732,8 +736,8 @@ def test_does_not_overwrite_existing(self, tmp_path): class TestOpenMPWorkaround: """Importing the knowledge tools module must set KMP_DUPLICATE_LIB_OK to - prevent a fatal OpenMP crash when FAISS and sentence-transformers (PyTorch) - both bundle libomp. + prevent a fatal OpenMP crash when multiple native deps (e.g. ChromaDB and + sentence-transformers / PyTorch) both bundle libomp. Without this, kb_search with rerank=true kills the server process, producing 'transport closed' in MCP clients.""" @@ -791,8 +795,10 @@ def test_search_omitted_rerank_passes_none(self, mock_kb): mock_kb.search.assert_called_once_with( query="test", collection="docs", + collections=None, top_k=5, rerank=None, + where=None, ) @@ -818,7 +824,11 @@ def test_single_collection_backward_compat(self, server, mock_kb): mock_kb.search.assert_called_once() def test_multi_collection_fanout(self, server, mock_kb): - """Searching multiple collections calls search for each.""" + """Multi-collection search delegates once to kb.search with collections=. + + Fan-out + fusion across collections is kb.search's job (covered by + TestFederatedSearch in test_knowledge_base.py); the handler just forwards. + """ mock_kb.search.return_value = [ make_search_result("result", "/file.md", 0, 0.9), ] @@ -833,7 +843,14 @@ def test_multi_collection_fanout(self, server, mock_kb): ) assert result["status"] == "ok" - assert mock_kb.search.call_count == 2 + mock_kb.search.assert_called_once_with( + query="test", + collection=None, + collections=["docs", "papers"], + top_k=5, + rerank=None, + where=None, + ) def test_no_collection_returns_error(self, server): result = call_tool( @@ -847,21 +864,12 @@ def test_no_collection_returns_error(self, server): assert result["status"] == "error" def test_multi_collection_merges_results(self, server, mock_kb): - """Results from multiple collections are merged and sorted.""" - call_count = [0] - - def varying_results(**kwargs): - call_count[0] += 1 - score = 0.9 if call_count[0] == 1 else 0.7 - return [ - make_search_result( - f"result_{call_count[0]}", - f"/file_{call_count[0]}.md", - score=score, - ) - ] - - mock_kb.search.side_effect = varying_results + """The handler returns kb.search's already-fused, sorted results.""" + # kb.search owns fusion now; it returns one merged, descending list. + mock_kb.search.return_value = [ + make_search_result("result_1", "/file_1.md", score=0.9), + make_search_result("result_2", "/file_2.md", score=0.7), + ] result = call_tool( server, @@ -877,28 +885,6 @@ def varying_results(**kwargs): scores = [r["score"] for r in result["results"]] assert scores == sorted(scores, reverse=True) - def test_missing_collection_skipped(self, server, mock_kb): - """A missing collection logs a warning but doesn't fail the search.""" - - def search_with_error(**kwargs): - if kwargs["collection"] == "missing": - raise ValueError("Collection 'missing' not found") - return [make_search_result("result", "/file.md")] - - mock_kb.search.side_effect = search_with_error - - result = call_tool( - server, - "kb_search", - { - "query": "test", - "collections": ["docs", "missing"], - }, - ) - - assert result["status"] == "ok" - assert result["result_count"] == 1 - class TestKbSearchSchema: diff --git a/tests/test_memory_extractor.py b/tests/test_memory_extractor.py new file mode 100644 index 0000000..082c32f --- /dev/null +++ b/tests/test_memory_extractor.py @@ -0,0 +1,102 @@ +"""Tests for the episodic-memory subscriber (MemoryExtractor). + +Tier-0 (mechanical, always) and Tier-1 (Judge, opt-in) write paths, and the +judge-failure → Tier-0 fallback. The KB is faked so these stay unit tests with +no embedding model. +""" + +import numpy as np + +from dsagt.memory import SESSION_MEMORY_COLLECTION, MemoryExtractor +from dsagt.traces import Trace + + +class FakeKB: + def __init__(self): + self.calls = [] + + def add_entries(self, *, texts, collection, metadatas, return_embeddings=False): + self.calls.append( + {"texts": texts, "collection": collection, "metadatas": metadatas} + ) + if return_embeddings: + return {"embeddings": np.zeros((len(texts), 3), dtype=np.float32)} + return {} + + +def _one_turn_trace(): + """A trace whose ``to_exchanges`` yields one exchange.""" + trace = Trace("t", "proj:s", "claude", "proj") + trace.add_agent_root("r1", "conv", start_time=1.0, prompt="filter the reads") + trace.add_llm_span( + "r1-0", + parent_id="r1", + start_time=1.0, + end_time=None, + request=[ + {"role": "user", "content": [{"type": "text", "text": "filter the reads"}]} + ], + response=[{"type": "text", "text": "kept 90 percent after QC"}], + ) + return trace + + +def test_tier0_indexes_turns_when_no_judge(tmp_path): + kb = FakeKB() + ext = MemoryExtractor(kb, runtime_dir=tmp_path, session_id="proj:s") + ext.write(_one_turn_trace()) + + assert len(kb.calls) == 1 + call = kb.calls[0] + assert call["collection"] == SESSION_MEMORY_COLLECTION + assert call["metadatas"][0]["source_type"] == "turn" + assert call["metadatas"][0]["tier"] == "0" + assert isinstance(call["metadatas"][0]["ts_epoch"], float) # for recency + assert "filter the reads" in call["texts"][0] + + +def test_tier1_stores_distilled_facts(tmp_path): + class StubJudge: + backend = "local" + + def distill(self, exchanges, tags): + return [{"text": "QC pass rate was 90%", "tag": "quality_control"}] + + kb = FakeKB() + ext = MemoryExtractor( + kb, runtime_dir=tmp_path, session_id="proj:s", judge=StubJudge() + ) + ext.write(_one_turn_trace()) + + assert len(kb.calls) == 1 + call = kb.calls[0] + assert call["texts"] == ["QC pass rate was 90%"] + assert call["metadatas"][0]["source_type"] == "fact" + assert call["metadatas"][0]["category"] == "quality_control" + assert call["metadatas"][0]["tier"] == "1" + assert isinstance(call["metadatas"][0]["ts_epoch"], float) # for recency + + +def test_judge_failure_falls_back_to_tier0(tmp_path): + class BoomJudge: + backend = "local" + + def distill(self, exchanges, tags): + raise RuntimeError("model OOM") + + kb = FakeKB() + ext = MemoryExtractor( + kb, runtime_dir=tmp_path, session_id="proj:s", judge=BoomJudge() + ) + ext.write(_one_turn_trace()) + + # Degraded to Tier-0 — data preserved, never blocked. + assert len(kb.calls) == 1 + assert kb.calls[0]["metadatas"][0]["tier"] == "0" + + +def test_empty_trace_writes_nothing(tmp_path): + kb = FakeKB() + ext = MemoryExtractor(kb, runtime_dir=tmp_path, session_id="proj:s") + ext.write(Trace("t", "proj:s", "claude", "proj")) + assert kb.calls == [] diff --git a/tests/test_observability.py b/tests/test_observability.py index f29d11e..6450655 100644 --- a/tests/test_observability.py +++ b/tests/test_observability.py @@ -54,15 +54,17 @@ def _reset_tracing(monkeypatch): exporter.clear() -def test_init_tracing_no_endpoint_raises(monkeypatch): - """init_tracing must fail loudly when no backend is configured — silent - no-op behavior would let a misconfigured subprocess run with tracing - silently dropped, which is exactly the kind of silent-fallback bug - DSAGT's design principles prohibit.""" +def test_init_tracing_outside_project_is_noop(monkeypatch): + """Serverless + never-raise: when cwd isn't a dsagt project dir (no + ``dsagt_config.yaml`` with a ``project``), ``init_tracing`` logs and + no-ops rather than raising — one-shot tools / tests outside a project + simply run untraced. The store itself never needs a server, so the + only reason to skip is "not in a project", which must not be fatal.""" monkeypatch.setattr(obs_module, "_initialized", False) monkeypatch.delenv("MLFLOW_TRACKING_URI", raising=False) - with pytest.raises(RuntimeError, match="no observability backend"): - init_tracing("test-service") + # Repo root has no dsagt_config.yaml → find_project_config returns None. + init_tracing("test-service") # must not raise + assert obs_module._initialized is False def test_traced_emits_span_with_args(_reset_tracing): @@ -193,8 +195,7 @@ def test_init_tracing_installs_mlflow_provider(monkeypatch): global, so every span flows into MLflow's trace store with full ``mlflow.spanInputs`` / ``mlflow.spanOutputs`` integration. Pins the set_experiment call (so the project's experiment exists) and verifies - the strategy pointers (_metadata_stamper, _llm_context_factory) get - bound. + the strategy pointer (_metadata_stamper) gets bound. """ captured: dict = {} @@ -222,21 +223,12 @@ def _fake_install(mlflow_url, project_name): monkeypatch.setattr(obs_module, "_initialized", False) monkeypatch.setattr(obs_module, "_metadata_stamper", None) - monkeypatch.setattr(obs_module, "_llm_context_factory", None) monkeypatch.setattr( obs_module, "find_project_config", lambda: (None, {"project": "my-project"}), ) - monkeypatch.setattr( - obs_module, - "_read_session_id_from_runtime", - lambda _pdir: None, - ) - # Skip autolog wiring — using service_name="dsagt-run" exits the - # autolog branch in init_tracing. The strategy pointers and provider - # install are what we actually care about here. try: init_tracing("dsagt-run", mlflow_url="http://localhost:5000/") finally: @@ -244,9 +236,8 @@ def _fake_install(mlflow_url, project_name): assert captured["installed_url"] == "http://localhost:5000/" assert captured["installed_project"] == "my-project" - # Strategy pointers should be bound to the MLflow-backed implementations. + # Strategy pointer should be bound to the MLflow-backed implementation. assert obs_module._metadata_stamper is obs_module._stamp_metadata_mlflow - assert obs_module._llm_context_factory is obs_module._mlflow_llm_context # --------------------------------------------------------------------------- @@ -382,34 +373,6 @@ def f(a, b, c=42, *, d=None): assert "d" not in span.attributes -def test_litellm_imports_at_observability_init_no_fallback(): - """Regression test for the deletion of `except ImportError: return` in - configure_litellm_retries. - - litellm is a hard dependency in pyproject.toml. If anyone re-introduces - a try/except around the import (turning litellm "optional" again), the - function would silently no-op and the rate-limit retry/backoff knobs - would never be configured — exactly the silent-degradation pattern - we're trying to eliminate. - - This test asserts the import succeeds AND the side-effects of - configure_litellm_retries actually happened. - """ - import litellm - from dsagt.observability import configure_litellm_retries - - # Mutate litellm state to known-bad values, then call configure and - # verify it stomped them. If configure ever silently no-ops on a - # caught ImportError, the asserts below would fail. - litellm.num_retries = -999 - litellm.request_timeout = -999.0 - - configure_litellm_retries(num_retries=7, request_timeout=42.0) - - assert litellm.num_retries == 7 - assert litellm.request_timeout == 42.0 - - # --------------------------------------------------------------------------- # Stage 1: KnowledgeBase instrumentation # --------------------------------------------------------------------------- @@ -438,11 +401,11 @@ def fake_embed(texts): mock_client = MagicMock() mock_client.embed = fake_embed - with patch("dsagt.knowledge._make_embedder", return_value=mock_client): + with patch("dsagt.knowledge.Embedder.create", return_value=mock_client): kb = KnowledgeBase( index_dir=tmp_path / f"kb_{backend}", default_embedder=backend, - embedder_kwargs={"model": model}, + model=model, ) try: yield kb @@ -540,63 +503,6 @@ def test_kb_add_entries_emits_span(_reset_tracing, tmp_path): assert spans["kb.add_entries"].attributes["n_entries"] == 3 -# --------------------------------------------------------------------------- -# Stage 2: LiteLLM retry wiring -# --------------------------------------------------------------------------- - - -def test_configure_litellm_retries_sets_module_globals(monkeypatch): - """configure_litellm_retries must set litellm.num_retries / request_timeout.""" - import litellm - - from dsagt.observability import configure_litellm_retries - - # Save and clear so the test owns the values. - monkeypatch.setattr(litellm, "num_retries", None, raising=False) - monkeypatch.setattr(litellm, "request_timeout", 0.0, raising=False) - - configure_litellm_retries(num_retries=7, request_timeout=42.0) - - assert litellm.num_retries == 7 - assert litellm.request_timeout == 42.0 - - -def test_configure_litellm_retries_works_without_tracing(monkeypatch): - """The retry knobs must be applied even when tracing is not initialized. - - This is the dsagt-setup-kb path: the long-running embed job that has to - survive rate limits also runs before any MLflow endpoint exists. - """ - import litellm - - from dsagt.observability import configure_litellm_retries - - monkeypatch.setattr(obs_module, "_initialized", False) - monkeypatch.setattr(obs_module, "_tracer_provider", None) - monkeypatch.setattr(litellm, "num_retries", None, raising=False) - - configure_litellm_retries(num_retries=3, request_timeout=120.0) - - assert litellm.num_retries == 3 - assert litellm.request_timeout == 120.0 - - -def test_kb_search_does_not_call_real_litellm(_reset_tracing, tmp_path): - """Sanity check: with a mocked embedder, no litellm.embedding call escapes.""" - from unittest.mock import patch as _patch - - exporter = _reset_tracing - with _patch("litellm.embedding") as mock_embed: - with _kb_with_mocked_embedder(tmp_path) as kb: - kb.add_entries(texts=["hello"], collection="tcoll") - kb.search("hello", collection="tcoll", top_k=1) - - # _kb_with_mocked_embedder patches _make_embedder, so litellm.embedding - # should never be called even though APIEmbeddingClient now uses it. - assert mock_embed.call_count == 0 - assert "kb.search" in _spans_by_name(exporter) - - # --------------------------------------------------------------------------- # Stage 3: tool execution spans # --------------------------------------------------------------------------- @@ -876,75 +782,3 @@ def test_search_registry_does_not_emit_span(_reset_tracing, tmp_path): spans = _spans_by_name(exporter) assert "registry.search" not in spans assert "registry.search_registry" not in spans - - -# --------------------------------------------------------------------------- -# extract_cache_stats — provider-agnostic cache-token reader -# --------------------------------------------------------------------------- -# -# Each provider returns cache stats under a different field name; LiteLLM -# doesn't backfill across them. Lock in the field-name handling so a -# regression can't silently hide cache hits in `dsagt info`. - - -def test_extract_cache_stats_anthropic_format(): - from dsagt.observability import extract_cache_stats - - usage = { - "prompt_tokens": 5000, - "completion_tokens": 100, - "cache_read_input_tokens": 3000, - "cache_creation_input_tokens": 2000, - } - assert extract_cache_stats(usage) == (3000, 2000) - - -def test_extract_cache_stats_openai_format(): - from dsagt.observability import extract_cache_stats - - # OpenAI/Azure: cached_tokens nested under prompt_tokens_details, no write field - usage = { - "prompt_tokens": 5000, - "completion_tokens": 100, - "prompt_tokens_details": {"cached_tokens": 2400, "audio_tokens": None}, - } - assert extract_cache_stats(usage) == (2400, 0) - - -def test_extract_cache_stats_gemini_format(): - from dsagt.observability import extract_cache_stats - - usage = {"prompt_tokens": 1000, "cached_content_token_count": 700} - assert extract_cache_stats(usage) == (700, 0) - - -def test_extract_cache_stats_deepseek_format(): - from dsagt.observability import extract_cache_stats - - # DeepSeek: prompt_cache_hit_tokens is the read; prompt_cache_miss_tokens - # is the COMPLEMENT (uncached prompt tokens), not a "write" — don't count it. - usage = { - "prompt_tokens": 1000, - "prompt_cache_hit_tokens": 600, - "prompt_cache_miss_tokens": 400, - } - assert extract_cache_stats(usage) == (600, 0) - - -def test_extract_cache_stats_no_cache_fields(): - from dsagt.observability import extract_cache_stats - - assert extract_cache_stats({"prompt_tokens": 100, "completion_tokens": 10}) == ( - 0, - 0, - ) - - -def test_extract_cache_stats_handles_garbage(): - from dsagt.observability import extract_cache_stats - - # Real responses occasionally have None or non-dict values where we expect dicts - assert extract_cache_stats({"prompt_tokens_details": None}) == (0, 0) - assert extract_cache_stats({"prompt_tokens_details": "garbage"}) == (0, 0) - assert extract_cache_stats(None) == (0, 0) - assert extract_cache_stats({}) == (0, 0) diff --git a/tests/test_outlier.py b/tests/test_outlier.py index 9bebed8..3706144 100644 --- a/tests/test_outlier.py +++ b/tests/test_outlier.py @@ -11,11 +11,11 @@ from dsagt.memory import CategoryCentroids, SuggestionQueue, check_and_queue_outliers - # --------------------------------------------------------------------------- # CategoryCentroids # --------------------------------------------------------------------------- + class TestCategoryCentroids: def test_first_entry_returns_zero_distance(self, tmp_path): @@ -79,6 +79,7 @@ def test_separate_categories(self, tmp_path): # SuggestionQueue # --------------------------------------------------------------------------- + class TestSuggestionQueue: def test_add_returns_id(self, tmp_path): @@ -129,6 +130,7 @@ def test_persistence(self, tmp_path): # check_and_queue_outliers # --------------------------------------------------------------------------- + class TestCheckAndQueueOutliers: def _make_embeddings(self, vectors): @@ -149,8 +151,13 @@ def test_flags_outlier(self, tmp_path): embeddings = self._make_embeddings([[0.0, 1.0, 0.0]]) # orthogonal ids = check_and_queue_outliers( - texts, categories, embeddings, centroids, queue, - threshold=0.3, session_id="s1", + texts, + categories, + embeddings, + centroids, + queue, + threshold=0.3, + session_id="s1", ) assert len(ids) == 1 @@ -170,8 +177,13 @@ def test_normal_fact_not_flagged(self, tmp_path): embeddings = self._make_embeddings([[0.98, 0.1, 0.0]]) # close ids = check_and_queue_outliers( - texts, categories, embeddings, centroids, queue, - threshold=0.3, session_id="s1", + texts, + categories, + embeddings, + centroids, + queue, + threshold=0.3, + session_id="s1", ) assert len(ids) == 0 @@ -187,8 +199,13 @@ def test_first_in_category_not_flagged(self, tmp_path): embeddings = self._make_embeddings([[0.5, 0.5, 0.5]]) ids = check_and_queue_outliers( - texts, categories, embeddings, centroids, queue, - threshold=0.1, session_id="s1", + texts, + categories, + embeddings, + centroids, + queue, + threshold=0.1, + session_id="s1", ) assert len(ids) == 0 @@ -203,7 +220,11 @@ def test_empty_category_skipped(self, tmp_path): embeddings = self._make_embeddings([[1.0, 0.0, 0.0]]) ids = check_and_queue_outliers( - texts, categories, embeddings, centroids, queue, + texts, + categories, + embeddings, + centroids, + queue, threshold=0.1, ) @@ -219,10 +240,12 @@ def test_centroids_saved(self, tmp_path): embeddings = self._make_embeddings([[1.0, 0.0]]) check_and_queue_outliers( - texts, categories, embeddings, centroids, queue, + texts, + categories, + embeddings, + centroids, + queue, threshold=0.3, ) assert (tmp_path / "centroids.json").exists() - - diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 9af66c1..d09eeb5 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -15,11 +15,11 @@ render_snakemake, ) - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- + def _write_record(trace_dir: Path, record: dict) -> Path: trace_dir.mkdir(parents=True, exist_ok=True) rid = record.get("record_id", "r0") @@ -60,6 +60,7 @@ def _make_record( # load_pipeline_records # --------------------------------------------------------------------------- + class TestLoadPipelineRecords: def test_loads_wrapper_records(self, tmp_path): @@ -86,16 +87,30 @@ def test_skips_proxy_only_records(self, tmp_path): assert len(records) == 0 def test_filters_by_session(self, tmp_path): - _write_record(tmp_path, _make_record("a", ["a"], session_id="s1", record_id="r1")) - _write_record(tmp_path, _make_record("b", ["b"], session_id="s2", record_id="r2")) + _write_record( + tmp_path, _make_record("a", ["a"], session_id="s1", record_id="r1") + ) + _write_record( + tmp_path, _make_record("b", ["b"], session_id="s2", record_id="r2") + ) records = load_pipeline_records(tmp_path, session_id="s1") assert len(records) == 1 assert records[0]["tool_name"] == "a" def test_sorted_by_timestamp(self, tmp_path): - _write_record(tmp_path, _make_record("late", ["late"], timestamp="2024-01-15T11:00:00Z", record_id="r2")) - _write_record(tmp_path, _make_record("early", ["early"], timestamp="2024-01-15T09:00:00Z", record_id="r1")) + _write_record( + tmp_path, + _make_record( + "late", ["late"], timestamp="2024-01-15T11:00:00Z", record_id="r2" + ), + ) + _write_record( + tmp_path, + _make_record( + "early", ["early"], timestamp="2024-01-15T09:00:00Z", record_id="r1" + ), + ) records = load_pipeline_records(tmp_path) assert records[0]["tool_name"] == "early" @@ -113,6 +128,7 @@ def test_nonexistent_directory(self, tmp_path): # build_dependency_graph # --------------------------------------------------------------------------- + class TestBuildDependencyGraph: def test_linear_dependency(self): @@ -161,6 +177,7 @@ def test_self_dependency_excluded(self): # render_bash # --------------------------------------------------------------------------- + class TestRenderBash: def test_basic_script(self): @@ -175,7 +192,9 @@ def test_basic_script(self): def test_includes_file_comments(self): records = [ - _make_record("fastp", ["fastp"], input_files=["raw.fq"], output_files=["clean.fq"]), + _make_record( + "fastp", ["fastp"], input_files=["raw.fq"], output_files=["clean.fq"] + ), ] deps = build_dependency_graph(records) script = render_bash(records, deps) @@ -212,12 +231,17 @@ def test_quotes_special_characters(self): # render_snakemake # --------------------------------------------------------------------------- + class TestRenderSnakemake: def test_basic_workflow(self): records = [ - _make_record("fastp", ["fastp", "-q", "20"], - input_files=["raw.fq"], output_files=["clean.fq"]), + _make_record( + "fastp", + ["fastp", "-q", "20"], + input_files=["raw.fq"], + output_files=["clean.fq"], + ), ] deps = build_dependency_graph(records) workflow = render_snakemake(records, deps) @@ -230,7 +254,12 @@ def test_basic_workflow(self): def test_multi_step(self): records = [ _make_record("fastp", ["fastp"], output_files=["clean.fq"]), - _make_record("megahit", ["megahit"], input_files=["clean.fq"], output_files=["contigs.fa"]), + _make_record( + "megahit", + ["megahit"], + input_files=["clean.fq"], + output_files=["contigs.fa"], + ), ] deps = build_dependency_graph(records) workflow = render_snakemake(records, deps) @@ -245,21 +274,29 @@ def test_multi_step(self): # reconstruct_pipeline (end-to-end) # --------------------------------------------------------------------------- + class TestReconstructPipeline: def test_bash_format(self, tmp_path): - _write_record(tmp_path, _make_record("fastp", ["fastp", "-q", "20"], record_id="r1")) + _write_record( + tmp_path, _make_record("fastp", ["fastp", "-q", "20"], record_id="r1") + ) script = reconstruct_pipeline(tmp_path, fmt="bash") assert "#!/usr/bin/env bash" in script assert "fastp -q 20" in script def test_snakemake_format(self, tmp_path): - _write_record(tmp_path, _make_record( - "fastp", ["fastp"], - input_files=["raw.fq"], output_files=["clean.fq"], - record_id="r1", - )) + _write_record( + tmp_path, + _make_record( + "fastp", + ["fastp"], + input_files=["raw.fq"], + output_files=["clean.fq"], + record_id="r1", + ), + ) workflow = reconstruct_pipeline(tmp_path, fmt="snakemake") assert "rule fastp_1:" in workflow @@ -270,8 +307,12 @@ def test_empty_returns_comment(self, tmp_path): assert "No execution records found" in result def test_session_filter(self, tmp_path): - _write_record(tmp_path, _make_record("a", ["a"], session_id="s1", record_id="r1")) - _write_record(tmp_path, _make_record("b", ["b"], session_id="s2", record_id="r2")) + _write_record( + tmp_path, _make_record("a", ["a"], session_id="s1", record_id="r1") + ) + _write_record( + tmp_path, _make_record("b", ["b"], session_id="s2", record_id="r2") + ) script = reconstruct_pipeline(tmp_path, session_id="s1", fmt="bash") assert "Step 1: a" in script @@ -279,22 +320,37 @@ def test_session_filter(self, tmp_path): def test_full_pipeline_with_deps(self, tmp_path): """Three-step pipeline: fastp → megahit → quast.""" - _write_record(tmp_path, _make_record( - "fastp", ["fastp", "-q", "20", "--in1", "raw.fq.gz"], - output_files=["clean.fq.gz"], - timestamp="2024-01-15T10:00:00Z", record_id="r1", - )) - _write_record(tmp_path, _make_record( - "megahit", ["megahit", "-r", "clean.fq.gz", "-o", "assembly"], - input_files=["clean.fq.gz"], - output_files=["assembly/final.contigs.fa"], - timestamp="2024-01-15T10:10:00Z", record_id="r2", - )) - _write_record(tmp_path, _make_record( - "quast", ["quast", "assembly/final.contigs.fa", "-o", "quast_out"], - input_files=["assembly/final.contigs.fa"], - timestamp="2024-01-15T10:20:00Z", record_id="r3", - )) + _write_record( + tmp_path, + _make_record( + "fastp", + ["fastp", "-q", "20", "--in1", "raw.fq.gz"], + output_files=["clean.fq.gz"], + timestamp="2024-01-15T10:00:00Z", + record_id="r1", + ), + ) + _write_record( + tmp_path, + _make_record( + "megahit", + ["megahit", "-r", "clean.fq.gz", "-o", "assembly"], + input_files=["clean.fq.gz"], + output_files=["assembly/final.contigs.fa"], + timestamp="2024-01-15T10:10:00Z", + record_id="r2", + ), + ) + _write_record( + tmp_path, + _make_record( + "quast", + ["quast", "assembly/final.contigs.fa", "-o", "quast_out"], + input_files=["assembly/final.contigs.fa"], + timestamp="2024-01-15T10:20:00Z", + record_id="r3", + ), + ) script = reconstruct_pipeline(tmp_path, session_id="s1", fmt="bash") diff --git a/tests/test_proxy_callback.py b/tests/test_proxy_callback.py deleted file mode 100644 index 369d73c..0000000 --- a/tests/test_proxy_callback.py +++ /dev/null @@ -1,226 +0,0 @@ -""" -Tests for the Phase 2 proxy callback (cache breakpoints + sidechannel -detection) in ``observability.py``. The callback is the only piece -LiteLLM autolog can't cover — request mutation (cache markers) and -canned-response detection (sidechannel) need the proxy hot path. - -LiteLLM's standard ``"otel"`` callback handles trace transport; -``DSAGTCallback`` only intercepts for these two intercept-time concerns. -""" -from __future__ import annotations - -import json -from pathlib import Path -from unittest.mock import patch - -import pytest - - -# --------------------------------------------------------------------------- -# _inject_cache_breakpoints -# --------------------------------------------------------------------------- - - -class TestInjectCacheBreakpoints: - """Anthropic prompt caching keys on the prefix UP TO each marked - block. We mark the last system text block + last tool definition - so subsequent turns within the 5-min TTL pay 10% on the cached - prefix. Providers without caching ignore the marker as a no-op.""" - - def test_marks_last_tool_definition(self): - from dsagt.observability import _inject_cache_breakpoints - messages: list = [] - kwargs = { - "tools": [ - {"name": "tool_a", "description": "a"}, - {"name": "tool_b", "description": "b"}, - ], - } - _inject_cache_breakpoints(messages, kwargs) - assert "cache_control" not in kwargs["tools"][0] - assert kwargs["tools"][1]["cache_control"] == {"type": "ephemeral"} - - def test_promotes_system_string_to_block_with_marker(self): - from dsagt.observability import _inject_cache_breakpoints - messages = [{"role": "system", "content": "you are helpful"}] - kwargs = {} - _inject_cache_breakpoints(messages, kwargs) - content = messages[0]["content"] - assert isinstance(content, list) - assert content[0]["type"] == "text" - assert content[0]["text"] == "you are helpful" - assert content[0]["cache_control"] == {"type": "ephemeral"} - - def test_marks_last_system_block(self): - from dsagt.observability import _inject_cache_breakpoints - messages = [{"role": "system", "content": [ - {"type": "text", "text": "rule 1"}, - {"type": "text", "text": "rule 2"}, - ]}] - kwargs = {} - _inject_cache_breakpoints(messages, kwargs) - blocks = messages[0]["content"] - assert "cache_control" not in blocks[0] - assert blocks[1]["cache_control"] == {"type": "ephemeral"} - - def test_no_tools_no_system_is_noop(self): - from dsagt.observability import _inject_cache_breakpoints - messages = [{"role": "user", "content": "hi"}] - kwargs = {} - _inject_cache_breakpoints(messages, kwargs) - assert "tools" not in kwargs - assert messages[0]["content"] == "hi" - - def test_only_first_system_message_marked(self): - """If multiple system messages somehow exist, only the first - gets stamped (loop breaks after first match).""" - from dsagt.observability import _inject_cache_breakpoints - messages = [ - {"role": "system", "content": "first"}, - {"role": "system", "content": "second"}, - ] - _inject_cache_breakpoints(messages, {}) - assert isinstance(messages[0]["content"], list) - assert messages[1]["content"] == "second" # untouched - - -# --------------------------------------------------------------------------- -# record_sidechannel_call / print_sidechannel_warning -# --------------------------------------------------------------------------- - - -class TestSidechannelDetection: - """``record_sidechannel_call`` writes to ``sidechannel.jsonl`` only - when ``kwargs["model"]`` matches ``SIDECHANNEL_CATCHALL_MODEL`` — - that's the wildcard's post-routing target, the only reliable - discriminator from primary / alias hits.""" - - def _kwargs(self, routed_to: str, requested: str | None = "gpt-4o-mini"): - slo = {} - if requested: - slo["model_group"] = f"openai/{requested}" - return {"model": routed_to, "standard_logging_object": slo} - - def test_records_when_routed_to_catchall(self, tmp_path, monkeypatch): - from dsagt.observability import ( - record_sidechannel_call, SIDECHANNEL_CATCHALL_MODEL, - SIDECHANNEL_LOG_FILENAME, - ) - monkeypatch.setenv("DSAGT_PRIMARY_MODEL", "real-model") - records_dir = tmp_path / "trace_archive" - records_dir.mkdir() - record_sidechannel_call(records_dir, self._kwargs(SIDECHANNEL_CATCHALL_MODEL)) - log_path = tmp_path / SIDECHANNEL_LOG_FILENAME - assert log_path.exists() - entry = json.loads(log_path.read_text().strip()) - assert entry["model"] == "gpt-4o-mini" - - def test_skipped_when_routed_to_primary(self, tmp_path, monkeypatch): - """Real upstream calls (primary entry or alias) shouldn't log — - only true mock hits do.""" - from dsagt.observability import ( - record_sidechannel_call, SIDECHANNEL_LOG_FILENAME, - ) - monkeypatch.setenv("DSAGT_PRIMARY_MODEL", "real-model") - records_dir = tmp_path / "trace_archive" - records_dir.mkdir() - record_sidechannel_call(records_dir, self._kwargs("openai/real-model")) - log_path = tmp_path / SIDECHANNEL_LOG_FILENAME - assert not log_path.exists() - - def test_no_op_when_primary_env_unset(self, tmp_path, monkeypatch): - """If the proxy never set DSAGT_PRIMARY_MODEL we don't know what - to compare against — refuse to log rather than misclassify.""" - from dsagt.observability import ( - record_sidechannel_call, SIDECHANNEL_CATCHALL_MODEL, - SIDECHANNEL_LOG_FILENAME, - ) - monkeypatch.delenv("DSAGT_PRIMARY_MODEL", raising=False) - records_dir = tmp_path / "trace_archive" - records_dir.mkdir() - record_sidechannel_call(records_dir, self._kwargs(SIDECHANNEL_CATCHALL_MODEL)) - assert not (tmp_path / SIDECHANNEL_LOG_FILENAME).exists() - - -class TestPrintSidechannelWarning: - """End-of-session warning: dedups by model name within the current - session, only emits ANSI on a TTY, says nothing when no entries - matched (most common case).""" - - def _write_log(self, project_dir: Path, entries: list[dict]): - from dsagt.observability import SIDECHANNEL_LOG_FILENAME - path = project_dir / SIDECHANNEL_LOG_FILENAME - path.write_text("\n".join(json.dumps(e) for e in entries) + "\n") - - def test_silent_when_no_log(self, tmp_path, capsys): - from dsagt.observability import print_sidechannel_warning - print_sidechannel_warning(tmp_path, "any-session") - assert capsys.readouterr().out == "" - - def test_silent_when_session_doesnt_match(self, tmp_path, capsys): - from dsagt.observability import print_sidechannel_warning - self._write_log(tmp_path, [ - {"model": "gpt-4o-mini", "session": "OTHER", "agent": "goose"}, - ]) - print_sidechannel_warning(tmp_path, "MINE") - assert capsys.readouterr().out == "" - - def test_lists_unique_models_with_counts(self, tmp_path, capsys): - from dsagt.observability import print_sidechannel_warning - self._write_log(tmp_path, [ - {"model": "gpt-4o-mini", "session": "S", "agent": "goose"}, - {"model": "gpt-4o-mini", "session": "S", "agent": "goose"}, - {"model": "claude-haiku-4-5", "session": "S", "agent": "claude"}, - ]) - print_sidechannel_warning(tmp_path, "S") - out = capsys.readouterr().out - assert "Sidechannel model calls intercepted" in out - assert "gpt-4o-mini" in out - assert "(2 calls)" in out - assert "claude-haiku-4-5" in out - assert "(1 call)" in out - - -# --------------------------------------------------------------------------- -# DSAGTCallback (the LiteLLM CustomLogger) -# --------------------------------------------------------------------------- - - -class TestDSAGTCallback: - """Minimal callback that hooks two LiteLLM events: - log_pre_api_call → cache injection - log_success_event → sidechannel detection. - Trace transport is via ``litellm.callbacks = ["otel"]``, not here. - """ - - def test_pre_api_call_injects_cache(self, tmp_path): - from dsagt.observability import _make_dsagt_callback - # litellm.integrations.custom_logger may not be installed in some - # CI envs; if so, skip — the callback is purely a litellm wrapper. - pytest.importorskip("litellm") - cb = _make_dsagt_callback(records_dir=tmp_path / "trace_archive") - messages = [{"role": "system", "content": "hi"}] - kwargs = {} - cb.log_pre_api_call(model="m", messages=messages, kwargs=kwargs) - assert messages[0]["content"][0]["cache_control"] == {"type": "ephemeral"} - - def test_success_event_records_sidechannel(self, tmp_path, monkeypatch): - from dsagt.observability import ( - _make_dsagt_callback, SIDECHANNEL_CATCHALL_MODEL, - SIDECHANNEL_LOG_FILENAME, - ) - pytest.importorskip("litellm") - monkeypatch.setenv("DSAGT_PRIMARY_MODEL", "real-model") - records_dir = tmp_path / "trace_archive" - records_dir.mkdir() - cb = _make_dsagt_callback(records_dir) - kwargs = { - "model": SIDECHANNEL_CATCHALL_MODEL, - "standard_logging_object": {"model_group": "openai/gpt-4o-mini"}, - } - cb.log_success_event(kwargs=kwargs, response_obj=None, - start_time=0, end_time=0) - log_path = tmp_path / SIDECHANNEL_LOG_FILENAME - assert log_path.exists() - entry = json.loads(log_path.read_text().strip()) - assert entry["model"] == "gpt-4o-mini" diff --git a/tests/test_recency.py b/tests/test_recency.py new file mode 100644 index 0000000..5277603 --- /dev/null +++ b/tests/test_recency.py @@ -0,0 +1,69 @@ +"""Recency weighting for episodic (session_memory) retrieval. + +``_apply_recency`` is a pure re-ranker, so these test the *behavior that matters* +without an embedder: a recent fact edges out a same-relevance stale one, while a +strongly-relevant old fact still beats an irrelevant recent one (the guard that +recency is a bounded boost, never a filter). +""" + +from dsagt.knowledge import _apply_recency + +DAY = 86400.0 +NOW = 1_000_000_000.0 + + +def _r(score, age_days, label): + ts = NOW - age_days * DAY + return {"chunk": {"text": label, "metadata": {"ts_epoch": ts}}, "score": score} + + +def _labels(results): + return [r["chunk"]["text"] for r in results] + + +def test_recent_edges_out_same_relevance_stale_fact(): + # Equal base relevance → the newer one wins purely on recency. + out = _apply_recency( + [_r(1.0, age_days=30, label="old"), _r(1.0, age_days=0, label="new")], + half_life_days=14, + now=NOW, + ) + assert _labels(out)[0] == "new" + + +def test_strong_old_fact_still_beats_irrelevant_recent_one(): + # The guard: recency is a bounded boost (≤ +50%), so a much-more-relevant old + # fact is never buried by a barely-relevant recent one. + out = _apply_recency( + [ + _r(1.0, age_days=60, label="relevant_old"), + _r(0.4, age_days=0, label="recent_junk"), + ], + half_life_days=14, + now=NOW, + ) + assert _labels(out)[0] == "relevant_old" + + +def test_half_life_controls_decay_strength(): + # At one half-life the boost halves: factor = 1 + 0.5 * 0.5 = 1.25. + (one,) = _apply_recency( + [_r(1.0, age_days=14, label="x")], half_life_days=14, now=NOW + ) + assert abs(one["recency_factor"] - 1.25) < 1e-9 + # Brand-new → full boost 1.5; very old → ~1.0 (no penalty below relevance). + (fresh,) = _apply_recency( + [_r(1.0, age_days=0, label="x")], half_life_days=14, now=NOW + ) + assert abs(fresh["recency_factor"] - 1.5) < 1e-9 + (ancient,) = _apply_recency( + [_r(1.0, age_days=3650, label="x")], half_life_days=14, now=NOW + ) + assert 1.0 <= ancient["recency_factor"] < 1.001 + + +def test_missing_ts_epoch_is_unweighted_not_dropped(): + res = [{"chunk": {"text": "no_ts", "metadata": {}}, "score": 1.0}] + (out,) = _apply_recency(res, half_life_days=14, now=NOW) + assert out["recency_factor"] == 1.0 + assert out["chunk"]["text"] == "no_ts" diff --git a/tests/test_registry_server.py b/tests/test_registry_server.py index dde1ba9..3af42c0 100644 --- a/tests/test_registry_server.py +++ b/tests/test_registry_server.py @@ -476,7 +476,6 @@ def _make_server_with_kb(tmp_path, tools=None): kb = KnowledgeBase( index_dir=tmp_path / "kb_index", default_embedder="local", - default_index="chroma", ) reg = ToolRegistry( source_tools_dir=str(source_dir), diff --git a/tests/test_run.py b/tests/test_run.py index fe37f03..f32d7b0 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -6,10 +6,7 @@ """ import json -import os -import sys from pathlib import Path -from unittest.mock import patch import pytest @@ -24,11 +21,11 @@ main, ) - # --------------------------------------------------------------------------- # Argument parsing # --------------------------------------------------------------------------- + class TestParseArgs: def test_basic(self): @@ -37,15 +34,26 @@ def test_basic(self): assert command == ["fastp", "-q", "20"] def test_all_flags(self): - args, command = _parse_args([ - "--tool", "megahit", - "--session", "sess-1", - "--record-id", "rec-42", - "--records-dir", "/tmp/records", - "--input-files", "a.fq,b.fq", - "--output-files", "out/contigs.fa", - "--", "megahit", "-1", "a.fq", - ]) + args, command = _parse_args( + [ + "--tool", + "megahit", + "--session", + "sess-1", + "--record-id", + "rec-42", + "--records-dir", + "/tmp/records", + "--input-files", + "a.fq,b.fq", + "--output-files", + "out/contigs.fa", + "--", + "megahit", + "-1", + "a.fq", + ] + ) assert args.tool == "megahit" assert args.session == "sess-1" assert args.record_id == "rec-42" @@ -72,6 +80,7 @@ def test_defaults(self): # File list parsing # --------------------------------------------------------------------------- + class TestParseFileList: def test_none(self): @@ -94,24 +103,26 @@ def test_trailing_comma(self): # Records directory resolution # --------------------------------------------------------------------------- + class TestResolveRecordsDir: def test_explicit_wins(self): assert _resolve_records_dir("/custom/dir") == Path("/custom/dir") def test_uses_cwd_dsagt_config(self, tmp_path, monkeypatch): - """No --records-dir → reads ``/dsagt_config.yaml`` and uses + """No --records-dir → reads ``/.dsagt/config.yaml`` and uses ``/trace_archive``. Env vars are not consulted; the project dir is the single source of truth.""" - (tmp_path / "dsagt_config.yaml").write_text("project: t\n") + (tmp_path / ".dsagt").mkdir() + (tmp_path / ".dsagt" / "config.yaml").write_text("project: t\n") monkeypatch.chdir(tmp_path) assert _resolve_records_dir(None) == tmp_path / "trace_archive" def test_no_config_in_cwd_raises(self, tmp_path, monkeypatch): - """If cwd has no dsagt_config.yaml, fail clearly — don't walk + """If cwd has no .dsagt/config.yaml, fail clearly — don't walk up the tree, don't fall back to env vars.""" monkeypatch.chdir(tmp_path) - with pytest.raises(ValueError, match="No dsagt_config.yaml"): + with pytest.raises(ValueError, match="No .dsagt/config.yaml"): _resolve_records_dir(None) @@ -119,6 +130,7 @@ def test_no_config_in_cwd_raises(self, tmp_path, monkeypatch): # Record writing # --------------------------------------------------------------------------- + class TestWriteRecord: def test_creates_directory_and_file(self, tmp_path): @@ -174,6 +186,7 @@ def test_record_is_valid_json(self, tmp_path): # run_and_record # --------------------------------------------------------------------------- + class TestRunAndRecord: def test_successful_command(self, tmp_path): @@ -227,18 +240,14 @@ def test_command_not_found(self, tmp_path): assert data["execution"]["return_code"] == 127 assert "command not found" in data["execution"]["stderr"] - def test_session_from_runtime(self, tmp_path, monkeypatch): - """Session ID falls back to /.runtime when not passed. + def test_session_from_state(self, tmp_path, monkeypatch): + """Session ID falls back to the current tag in ``.dsagt/state.yaml`` + when not passed — the MCP server mints it there at startup and + ``dsagt-run`` (cwd == project dir) reads it.""" + from dsagt.session import append_session, write_config_file, build_config - Single source of truth: dsagt mlflow writes session_id into - .runtime; services read from there, not from env vars. - """ - (tmp_path / "dsagt_config.yaml").write_text( - "project: t\nmlflow: {port: 5000}\n" - ) - (tmp_path / ".runtime").write_text( - json.dumps({"session_id": "runtime-session"}) - ) + write_config_file(tmp_path, build_config("t", "claude")) + append_session(tmp_path) # mints session id 1 → tag "t-1" monkeypatch.chdir(tmp_path) run_and_record( tool_name="t", @@ -248,16 +257,14 @@ def test_session_from_runtime(self, tmp_path, monkeypatch): ) data = json.loads(list(tmp_path.glob("*_test-004.json"))[0].read_text()) - assert data["session_id"] == "runtime-session" + assert data["session_id"] == "t-1" - def test_explicit_session_overrides_runtime(self, tmp_path, monkeypatch): - """Explicit --session takes precedence over .runtime.""" - (tmp_path / "dsagt_config.yaml").write_text( - "project: t\nmlflow: {port: 5000}\n" - ) - (tmp_path / ".runtime").write_text( - json.dumps({"session_id": "runtime-session"}) - ) + def test_explicit_session_overrides_state(self, tmp_path, monkeypatch): + """Explicit --session takes precedence over the state-file tag.""" + from dsagt.session import append_session, write_config_file, build_config + + write_config_file(tmp_path, build_config("t", "claude")) + append_session(tmp_path) monkeypatch.chdir(tmp_path) run_and_record( tool_name="t", @@ -297,7 +304,9 @@ def test_timestamps_are_populated(self, tmp_path): data = json.loads(list(tmp_path.glob("*.json"))[0].read_text()) assert data["execution"]["timestamp_start"] assert data["execution"]["timestamp_end"] - assert data["execution"]["timestamp_start"] <= data["execution"]["timestamp_end"] + assert ( + data["execution"]["timestamp_start"] <= data["execution"]["timestamp_end"] + ) def test_auto_generates_record_id(self, tmp_path): """Omitting record_id auto-generates one.""" @@ -316,60 +325,48 @@ def test_auto_generates_record_id(self, tmp_path): # main() CLI entry point # --------------------------------------------------------------------------- + class TestMain: @pytest.fixture(autouse=True) def _mlflow_file_store(self, tmp_path, monkeypatch): """Point MLflow tracing at a scratch file-store so init_tracing has a real backend. In production dsagt-run runs with cwd inside the - project directory, where ``dsagt_config.yaml`` (project name + - mlflow port) and ``.runtime`` (session id) live; tests mirror - that by writing both files in tmp_path and chdir-ing into it. - - Also stub the OTLPSpanExporter — init_tracing now builds one pointed - at the resolved MLflow URL, and a file:// URL would otherwise emit - async export-failure warnings to stderr. + project directory, where ``.dsagt/config.yaml`` (project name) lives + and the session id comes from ``.dsagt/state.yaml``; + tests mirror that by chdir-ing into tmp_path. """ - # dsagt_config.yaml carries the resolved MLflow URL via a synthetic - # port; tests use a file:// store, so monkeypatch the resolver to - # return the file:// URI directly. + # Serverless: init_tracing resolves a sqlite store from the project + # dir via MLflow's native provider — no OTLP exporter. Stub the + # resolver to a known sqlite URI so a shell-set MLFLOW_TRACKING_URI + # can't redirect the test. from dsagt import observability as obs_module - cfg = {"project": "test", "mlflow": {"port": 5000}} + + cfg = {"project": "test"} monkeypatch.setattr( - obs_module, "find_project_config", + obs_module, + "find_project_config", lambda: (tmp_path, cfg), ) monkeypatch.setattr( - obs_module, "_mlflow_url_from_config", - lambda c: f"file://{tmp_path}/mlruns", - ) - - class _NoopExporter: - def __init__(self, *args, **kwargs): - pass - - def export(self, spans): - from opentelemetry.sdk.trace.export import SpanExportResult - return SpanExportResult.SUCCESS - - def shutdown(self): - pass - - def force_flush(self, timeout_millis=30000): - return True - - monkeypatch.setattr( - "opentelemetry.exporter.otlp.proto.http.trace_exporter.OTLPSpanExporter", - _NoopExporter, + obs_module, + "resolve_tracking_uri", + lambda c: f"sqlite:///{tmp_path}/mlflow.db", ) def test_basic_invocation(self, tmp_path): """main() runs a command and returns its exit code.""" - exit_code = main([ - "--tool", "echo_tool", - "--records-dir", str(tmp_path), - "--", "echo", "from main", - ]) + exit_code = main( + [ + "--tool", + "echo_tool", + "--records-dir", + str(tmp_path), + "--", + "echo", + "from main", + ] + ) assert exit_code == 0 records = list(tmp_path.glob("*.json")) @@ -377,20 +374,29 @@ def test_basic_invocation(self, tmp_path): def test_empty_command_returns_1(self, tmp_path): """No command after '--' returns exit code 1.""" - exit_code = main([ - "--tool", "empty", - "--records-dir", str(tmp_path), - "--", - ]) + exit_code = main( + [ + "--tool", + "empty", + "--records-dir", + str(tmp_path), + "--", + ] + ) assert exit_code == 1 def test_exit_code_propagation(self, tmp_path): """main() returns the wrapped command's exit code.""" - exit_code = main([ - "--tool", "fail", - "--records-dir", str(tmp_path), - "--", "bash", "-c", "exit 7", - ]) + exit_code = main( + [ + "--tool", + "fail", + "--records-dir", + str(tmp_path), + "--", + "bash", + "-c", + "exit 7", + ] + ) assert exit_code == 7 - - diff --git a/tests/test_server_startup.py b/tests/test_server_startup.py index 6d3960e..8c0b0f4 100644 --- a/tests/test_server_startup.py +++ b/tests/test_server_startup.py @@ -29,7 +29,7 @@ class TestServerEntryPoint: def test_fails_fast_without_project_config(self, tmp_path): - """Run from a dir with no dsagt_config.yaml → clean fail-fast. + """Run from a dir with no .dsagt/config.yaml → clean fail-fast. ``dsagt-server`` discovers its project from cwd; launched anywhere else it must say so rather than boot a half-configured server. @@ -39,34 +39,41 @@ def test_fails_fast_without_project_config(self, tmp_path): proc = start_server(_SERVER_CMD, cwd=str(empty)) rc = proc.wait(timeout=15) assert rc != 0 - assert "no dsagt_config.yaml in cwd" in proc.stderr.read() + assert "no .dsagt/config.yaml in cwd" in proc.stderr.read() - def test_fails_fast_without_observability_backend(self, tmp_path): - """A project config lacking ``mlflow.port`` → init_tracing fails fast. + def test_mints_session_into_state_on_boot(self, tmp_path): + """The server owns the session lifecycle: on boot it appends a session + entry to ``.dsagt/state.yaml`` (serverless — no MLflow backend needed). - The merged server requires an observability backend (it autologs every - LLM call into MLflow); booting without one is a misconfiguration. + Session minting happens before the (slow) KB build, so the state file + appears within a second; we poll for it, then terminate. """ + import time + import yaml project = tmp_path / "runtime" - project.mkdir() + (project / ".dsagt").mkdir(parents=True) config = { "project": "test", "agent": "claude", - "embedding": { - "backend": "api", - "model": "test-model", - "base_url": "http://localhost:9999", - "api_key": "test-fake-key", - }, - "knowledge": {"chunk_size": 1024, "vector_db": "chroma", "rerank": False}, - # no mlflow.port + "embedding": {"backend": "local", "model": "BAAI/bge-small-en-v1.5"}, + "knowledge": {"chunk_size": 1024, "rerank": False}, } - (project / "dsagt_config.yaml").write_text( + (project / ".dsagt" / "config.yaml").write_text( yaml.dump(config, default_flow_style=False) ) + state_file = project / ".dsagt" / "state.yaml" proc = start_server(_SERVER_CMD, cwd=str(project)) - rc = proc.wait(timeout=15) - assert rc != 0 - assert "no observability backend configured" in proc.stderr.read() + try: + for _ in range(60): # up to ~12s + if state_file.exists(): + break + time.sleep(0.2) + assert state_file.exists(), "server did not mint a session into state.yaml" + state = yaml.safe_load(state_file.read_text()) + assert state["sessions"][-1]["id"] == 1 + assert state["sessions"][-1]["started_at"].endswith("Z") + finally: + proc.terminate() + proc.wait(timeout=10) diff --git a/tests/test_setup_core_kb.py b/tests/test_setup_core_kb.py index ff5472d..a738a7d 100644 --- a/tests/test_setup_core_kb.py +++ b/tests/test_setup_core_kb.py @@ -1,10 +1,10 @@ """ -Tests for dsagt-setup-kb internals. +Tests for the knowledge-base asset builder (dsagt.commands.setup_core_kb), +the engine behind ``dsagt init``'s KB provisioning. -These cover the helpers in dsagt.commands.setup_core_kb that don't require -network access — the git clone subprocess is mocked so the tests can run -offline. The actual end-to-end behavior of dsagt-setup-kb against real -upstream repos is exercised manually. +These cover the helpers that don't require network access — the git clone +subprocess is mocked so the tests can run offline. The actual end-to-end +behavior against real upstream repos is exercised manually. """ from __future__ import annotations @@ -16,12 +16,18 @@ import pytest +import numpy as np + from dsagt.commands.setup_core_kb import ( + DEFAULT_ASSETS, DEFAULT_EXCLUDE_PATTERNS, + all_assets, + asset_collection_name, clone_github, + ensure_assets, + resolve_assets, ) - # --------------------------------------------------------------------------- # clone_github top-level-files behavior # --------------------------------------------------------------------------- @@ -135,3 +141,80 @@ def test_default_exclude_patterns_keeps_packaging_metadata(): f"DEFAULT_EXCLUDE_PATTERNS contains packaging metadata files " f"that the agent needs: {overlap}" ) + + +# --------------------------------------------------------------------------- +# Installable-asset selection (--include / --exclude namespace) +# --------------------------------------------------------------------------- + + +class TestResolveAssets: + + def test_default_is_tools_plus_genesis(self): + assert resolve_assets() == list(DEFAULT_ASSETS) == ["tools", "genesis"] + + def test_include_all_is_everything(self): + assert resolve_assets(include=["all"]) == all_assets() + + def test_include_subset_returns_canonical_order(self): + # input order shouldn't matter — cheap assets always built first. + assert resolve_assets(include=["aidrin", "tools"]) == ["tools", "aidrin"] + + def test_exclude_trims_the_default_set(self): + assert resolve_assets(exclude=["genesis"]) == ["tools"] + + def test_exclude_all_is_empty(self): + assert resolve_assets(exclude=["all"]) == [] + + def test_unknown_asset_raises(self): + with pytest.raises(ValueError, match="unknown KB asset"): + resolve_assets(include=["not-a-real-asset"]) + + def test_include_exclude_mutually_exclusive(self): + with pytest.raises(ValueError, match="mutually exclusive"): + resolve_assets(include=["tools"], exclude=["genesis"]) + + +class TestAssetCollectionName: + + def test_tools(self): + assert asset_collection_name("tools") == "tools" + + def test_catalog_uses_catalog_prefix(self): + name = asset_collection_name("genesis") + assert name.startswith("skills_catalog__") and "genesis" in name + + def test_scientific_collection_is_its_own_name(self): + assert asset_collection_name("nemo_curator") == "nemo_curator" + + def test_unknown_raises(self): + with pytest.raises(ValueError, match="unknown KB asset"): + asset_collection_name("bogus") + + +class TestEnsureAssetsTools: + """``ensure_assets`` for the bundled-tools asset, with a mocked embedder + so the test stays offline (no model download, no git clone).""" + + def _fake_embedder(self): + emb = MagicMock() + emb.embed = lambda texts: np.full((len(texts), 8), 0.1, dtype=np.float32) + return emb + + def test_builds_tools_collection(self, tmp_path): + with patch( + "dsagt.knowledge.Embedder.create", return_value=self._fake_embedder() + ): + result = ensure_assets(["tools"], tmp_path) + assert "tools" in result["built"] + # ChromaIndex.save writes chroma_ids.json — the collection marker. + assert (tmp_path / "tools" / "chroma_ids.json").exists() + + def test_is_idempotent(self, tmp_path): + with patch( + "dsagt.knowledge.Embedder.create", return_value=self._fake_embedder() + ): + ensure_assets(["tools"], tmp_path) + second = ensure_assets(["tools"], tmp_path) + assert second["skipped"] == ["tools"] + assert second["built"] == [] diff --git a/tests/test_site_config.yaml.example b/tests/test_site_config.yaml.example index eb47c3c..58f3844 100755 --- a/tests/test_site_config.yaml.example +++ b/tests/test_site_config.yaml.example @@ -10,7 +10,7 @@ project: test-integration agent: claude llm: - model: claude-sonnet-4-20250514 # LLM model the proxy routes to + model: claude-sonnet-4-20250514 # LLM model name at your endpoint base_url: https://api.example.com # LLM API endpoint (Anthropic-compatible) api_key: your-llm-api-key # API key for LLM service @@ -19,9 +19,6 @@ embedding: base_url: https://api.example.com/v1 # OpenAI-compatible embeddings URL api_key: your-embedding-api-key # API key for embedding service -proxy: - port: 14000 # test proxy port (offset to avoid conflicts) - mlflow: port: 15001 # test MLflow port backend: sqlite diff --git a/tests/test_skill_discovery.py b/tests/test_skill_discovery.py index 489bcf5..b955d07 100644 --- a/tests/test_skill_discovery.py +++ b/tests/test_skill_discovery.py @@ -41,8 +41,16 @@ def __init__(self, collections, hits_by_collection=None, index_dir="/tmp/none"): self._hits = hits_by_collection or {} self.index_dir = index_dir - def search(self, query, collection, top_k=5): - return self._hits.get(collection, [])[:top_k] + def search(self, query, collection=None, collections=None, top_k=5, **kwargs): + # Mirror KnowledgeBase.search's fan-out surface; a simplified stand-in + # that merges by raw score (real cross-collection RRF is exercised in + # test_knowledge_base.TestFederatedSearch). + targets = collections or ([collection] if collection else []) + hits: list[dict] = [] + for c in targets: + hits.extend(self._hits.get(c, [])) + hits.sort(key=lambda h: h["score"], reverse=True) + return hits[:top_k] def _hit(name, text, source, score, tags=""): diff --git a/tests/test_skill_tools.py b/tests/test_skill_tools.py index 76485a3..84fa0a9 100644 --- a/tests/test_skill_tools.py +++ b/tests/test_skill_tools.py @@ -32,7 +32,6 @@ def _make_skill_server(tmp_path): kb = KnowledgeBase( index_dir=tmp_path / "kb_index", default_embedder="local", - default_index="chroma", ) skill_reg = SkillRegistry( source_skills_dir=None, # package default (empty bundled is fine) @@ -185,12 +184,12 @@ class TestSkillSources: def test_list_skill_sources_returns_known(self, mock_kb): server = create_skill_server(kb=mock_kb) result = call_tool_json(server, "list_skill_sources", {}) - assert "scientific" in result["sources"] + assert "k-dense-ai" in result["sources"] # Nothing synced → every known source flagged available, not synced. - assert result["sources"]["scientific"]["synced"] is False - assert result["sources"]["scientific"]["indexed"] == 0 + assert result["sources"]["k-dense-ai"]["synced"] is False + assert result["sources"]["k-dense-ai"]["indexed"] == 0 assert result["other_synced_collections"] == [] - assert "scientific" in result["note"] + assert "k-dense-ai" in result["note"] def test_add_skill_source_bad_source_errors(self, mock_kb): server = create_skill_server(kb=mock_kb) diff --git a/tests/test_skills_catalog.py b/tests/test_skills_catalog.py index 7586e59..334561f 100644 --- a/tests/test_skills_catalog.py +++ b/tests/test_skills_catalog.py @@ -56,7 +56,8 @@ def test_known_source_genesis_covers_whole_skills_tree(): def test_persist_source_to_config_appends_and_dedupes(tmp_path): import yaml - cfg = tmp_path / "dsagt_config.yaml" + (tmp_path / ".dsagt").mkdir() + cfg = tmp_path / ".dsagt" / "config.yaml" cfg.write_text(yaml.dump({"project": "p", "skills": {"sources": []}})) spec = { "name": "anthropic", @@ -75,7 +76,7 @@ def test_persist_source_to_config_appends_and_dedupes(tmp_path): def test_resolve_source_known_url_and_shorthand(): assert ( - sc.resolve_source("scientific")["url"] == sc.KNOWN_SOURCES["scientific"]["url"] + sc.resolve_source("k-dense-ai")["url"] == sc.KNOWN_SOURCES["k-dense-ai"]["url"] ) assert ( sc.resolve_source("https://github.com/a/b")["url"] == "https://github.com/a/b" @@ -278,7 +279,6 @@ def test_mirror_truncates_long_description(tmp_path): ("claude", ".claude/skills"), ("goose", ".agents/skills"), ("cline", ".cline/skills"), - ("roo", ".roo/skills"), ("codex", ".agents/skills"), ], ) diff --git a/tests/test_tool_executions.py b/tests/test_tool_executions.py index 775471c..22ac09d 100644 --- a/tests/test_tool_executions.py +++ b/tests/test_tool_executions.py @@ -18,18 +18,18 @@ from dsagt.provenance import ( TOOL_EXECUTIONS_COLLECTION as COLLECTION_NAME, + ToolUseIndexer, execution_metadata, - index_execution_record, index_trace_archive, render_execution_text, ) -from dsagt.knowledge import CollectionRoute, KnowledgeBase - +from dsagt.knowledge import KnowledgeBase # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- + def fake_embed(texts: list[str]) -> np.ndarray: dim = 8 vecs = np.zeros((len(texts), dim), dtype=np.float32) @@ -100,6 +100,7 @@ def make_wrapper_record( # render_execution_text # --------------------------------------------------------------------------- + class TestRenderExecutionText: def test_proxy_record_uses_intent(self): @@ -171,6 +172,7 @@ def test_empty_record(self): # execution_metadata # --------------------------------------------------------------------------- + class TestExecutionMetadata: def test_proxy_record_metadata(self): @@ -220,46 +222,47 @@ def test_top_level_fields_preferred(self): # --------------------------------------------------------------------------- -# index_execution_record +# ToolUseIndexer — idempotent, incremental heartbeat indexing # --------------------------------------------------------------------------- -class TestIndexExecutionRecord: - def test_indexes_into_tool_executions_collection(self, tmp_path): - """Single record is indexed into the tool_executions collection.""" - with patch("dsagt.knowledge._make_embedder") as mock_make: - mock_embedder = MagicMock() - mock_embedder.embed = fake_embed - mock_make.return_value = mock_embedder - - kb = KnowledgeBase(index_dir=tmp_path / "kb") - record = make_proxy_record() - result = index_execution_record(record, kb) - - assert result["collection"] == COLLECTION_NAME - assert result["entries_added"] == 1 - - results = kb.search( - "fastp quality", collection=COLLECTION_NAME, - top_k=5, rerank=False, - ) - assert len(results) > 0 - assert "fastp" in results[0]["chunk"]["text"] +class TestToolUseIndexer: - kb.close() - - def test_indexes_wrapper_record(self, tmp_path): - """Wrapper-only record is also indexable.""" - with patch("dsagt.knowledge._make_embedder") as mock_make: + def _write(self, trace_dir: Path, record: dict): + trace_dir.mkdir(parents=True, exist_ok=True) + rid = record["record_id"] + (trace_dir / f"{record.get('tool_name', 'x')}_{rid}.json").write_text( + json.dumps(record) + ) + + def test_incremental_and_idempotent(self, tmp_path): + """Each tick indexes only new records; a re-tick with nothing new is a + no-op (the bug the cursor-less batch had — re-indexing everything).""" + with patch("dsagt.knowledge.Embedder.create") as mock_make: mock_embedder = MagicMock() mock_embedder.embed = fake_embed mock_make.return_value = mock_embedder - kb = KnowledgeBase(index_dir=tmp_path / "kb") - record = make_wrapper_record() - result = index_execution_record(record, kb) - - assert result["entries_added"] == 1 + pdir = tmp_path / "proj" + (pdir / ".dsagt").mkdir(parents=True) + kb = KnowledgeBase(index_dir=pdir / "kb") + indexer = ToolUseIndexer(kb, pdir) + + r1 = make_proxy_record() + r1["record_id"] = "r1" + self._write(pdir / "trace_archive", r1) + assert indexer.tick() == 1 # first record indexed + assert indexer.tick() == 0 # nothing new → no-op (idempotent) + + r2 = make_proxy_record() + r2["record_id"] = "r2" + self._write(pdir / "trace_archive", r2) + assert indexer.tick() == 1 # only the new one + assert indexer.tick() == 0 + + # The ack set persists exactly the indexed record ids. + acks = json.loads((pdir / ".dsagt" / "tool_use_acks.json").read_text()) + assert set(acks) == {"r1", "r2"} kb.close() @@ -267,6 +270,7 @@ def test_indexes_wrapper_record(self, tmp_path): # index_trace_archive # --------------------------------------------------------------------------- + class TestIndexTraceArchive: def _write_records(self, trace_dir: Path, records: list[dict]): @@ -279,12 +283,15 @@ def _write_records(self, trace_dir: Path, records: list[dict]): def test_indexes_all_records(self, tmp_path): """Indexes all records in trace_dir.""" trace_dir = tmp_path / "trace_archive" - self._write_records(trace_dir, [ - make_proxy_record(record_id="t1"), - make_wrapper_record(tool="megahit", record_id="t2"), - ]) - - with patch("dsagt.knowledge._make_embedder") as mock_make: + self._write_records( + trace_dir, + [ + make_proxy_record(record_id="t1"), + make_wrapper_record(tool="megahit", record_id="t2"), + ], + ) + + with patch("dsagt.knowledge.Embedder.create") as mock_make: mock_embedder = MagicMock() mock_embedder.embed = fake_embed mock_make.return_value = mock_embedder @@ -300,12 +307,15 @@ def test_indexes_all_records(self, tmp_path): def test_skips_already_indexed(self, tmp_path): """Records with IDs in indexed_ids are skipped.""" trace_dir = tmp_path / "trace_archive" - self._write_records(trace_dir, [ - make_proxy_record(record_id="t1"), - make_proxy_record(record_id="t2"), - ]) - - with patch("dsagt.knowledge._make_embedder") as mock_make: + self._write_records( + trace_dir, + [ + make_proxy_record(record_id="t1"), + make_proxy_record(record_id="t2"), + ], + ) + + with patch("dsagt.knowledge.Embedder.create") as mock_make: mock_embedder = MagicMock() mock_embedder.embed = fake_embed mock_make.return_value = mock_embedder @@ -322,12 +332,15 @@ def test_skips_already_indexed(self, tmp_path): def test_updates_indexed_ids(self, tmp_path): """indexed_ids set is updated with newly indexed record IDs.""" trace_dir = tmp_path / "trace_archive" - self._write_records(trace_dir, [ - make_proxy_record(record_id="t1"), - make_wrapper_record(record_id="t2"), - ]) - - with patch("dsagt.knowledge._make_embedder") as mock_make: + self._write_records( + trace_dir, + [ + make_proxy_record(record_id="t1"), + make_wrapper_record(record_id="t2"), + ], + ) + + with patch("dsagt.knowledge.Embedder.create") as mock_make: mock_embedder = MagicMock() mock_embedder.embed = fake_embed mock_make.return_value = mock_embedder @@ -344,7 +357,7 @@ def test_empty_directory(self, tmp_path): trace_dir = tmp_path / "trace_archive" trace_dir.mkdir() - with patch("dsagt.knowledge._make_embedder") as mock_make: + with patch("dsagt.knowledge.Embedder.create") as mock_make: mock_embedder = MagicMock() mock_embedder.embed = fake_embed mock_make.return_value = mock_embedder @@ -358,7 +371,7 @@ def test_empty_directory(self, tmp_path): def test_nonexistent_directory(self, tmp_path): """Non-existent trace_dir returns zeros.""" - with patch("dsagt.knowledge._make_embedder") as mock_make: + with patch("dsagt.knowledge.Embedder.create") as mock_make: mock_embedder = MagicMock() mock_embedder.embed = fake_embed mock_make.return_value = mock_embedder @@ -372,12 +385,19 @@ def test_nonexistent_directory(self, tmp_path): def test_skips_records_without_intent_or_execution(self, tmp_path): """Records missing both intent and execution are skipped.""" trace_dir = tmp_path / "trace_archive" - self._write_records(trace_dir, [ - {"record_id": "bad", "tool_name": "unknown", "report": {"agent_output": "something"}}, - make_proxy_record(record_id="good"), - ]) - - with patch("dsagt.knowledge._make_embedder") as mock_make: + self._write_records( + trace_dir, + [ + { + "record_id": "bad", + "tool_name": "unknown", + "report": {"agent_output": "something"}, + }, + make_proxy_record(record_id="good"), + ], + ) + + with patch("dsagt.knowledge.Embedder.create") as mock_make: mock_embedder = MagicMock() mock_embedder.embed = fake_embed mock_make.return_value = mock_embedder @@ -392,11 +412,14 @@ def test_skips_records_without_intent_or_execution(self, tmp_path): def test_accepts_wrapper_only_records(self, tmp_path): """Wrapper-only records (no intent) are valid and indexed.""" trace_dir = tmp_path / "trace_archive" - self._write_records(trace_dir, [ - make_wrapper_record(record_id="w1"), - ]) - - with patch("dsagt.knowledge._make_embedder") as mock_make: + self._write_records( + trace_dir, + [ + make_wrapper_record(record_id="w1"), + ], + ) + + with patch("dsagt.knowledge.Embedder.create") as mock_make: mock_embedder = MagicMock() mock_embedder.embed = fake_embed mock_make.return_value = mock_embedder @@ -413,7 +436,7 @@ def test_idempotent_reindex(self, tmp_path): trace_dir = tmp_path / "trace_archive" self._write_records(trace_dir, [make_proxy_record(record_id="t1")]) - with patch("dsagt.knowledge._make_embedder") as mock_make: + with patch("dsagt.knowledge.Embedder.create") as mock_make: mock_embedder = MagicMock() mock_embedder.embed = fake_embed mock_make.return_value = mock_embedder @@ -434,6 +457,7 @@ def test_idempotent_reindex(self, tmp_path): # End-to-end: index + filtered search # --------------------------------------------------------------------------- + class TestIndexAndSearch: def _index_records(self, tmp_path, records): @@ -455,7 +479,7 @@ def test_search_by_tool_name(self, tmp_path): ] trace_dir = self._index_records(tmp_path, records) - with patch("dsagt.knowledge._make_embedder") as mock_make: + with patch("dsagt.knowledge.Embedder.create") as mock_make: mock_embedder = MagicMock() mock_embedder.embed = fake_embed mock_make.return_value = mock_embedder @@ -466,7 +490,8 @@ def test_search_by_tool_name(self, tmp_path): results = kb.search( "quality filtering parameters", collection=COLLECTION_NAME, - top_k=10, rerank=False, + top_k=10, + rerank=False, where={"tool_name": "fastp"}, ) for r in results: @@ -481,7 +506,7 @@ def test_search_by_session(self, tmp_path): ] trace_dir = self._index_records(tmp_path, records) - with patch("dsagt.knowledge._make_embedder") as mock_make: + with patch("dsagt.knowledge.Embedder.create") as mock_make: mock_embedder = MagicMock() mock_embedder.embed = fake_embed mock_make.return_value = mock_embedder @@ -492,7 +517,8 @@ def test_search_by_session(self, tmp_path): results = kb.search( "fastp", collection=COLLECTION_NAME, - top_k=10, rerank=False, + top_k=10, + rerank=False, where={"session_id": "s1"}, ) for r in results: @@ -507,7 +533,7 @@ def test_search_wrapper_records_by_return_code(self, tmp_path): ] trace_dir = self._index_records(tmp_path, records) - with patch("dsagt.knowledge._make_embedder") as mock_make: + with patch("dsagt.knowledge.Embedder.create") as mock_make: mock_embedder = MagicMock() mock_embedder.embed = fake_embed mock_make.return_value = mock_embedder @@ -518,7 +544,8 @@ def test_search_wrapper_records_by_return_code(self, tmp_path): results = kb.search( "tool execution", collection=COLLECTION_NAME, - top_k=10, rerank=False, + top_k=10, + rerank=False, where={"return_code": 1}, ) for r in results: diff --git a/tests/test_trace_pipeline.py b/tests/test_trace_pipeline.py new file mode 100644 index 0000000..5d5fd5e --- /dev/null +++ b/tests/test_trace_pipeline.py @@ -0,0 +1,224 @@ +"""Parity test: our pipeline vs MLflow's own claude_code autolog. + +The same Claude transcript is logged two ways into one serverless sqlite store: + oracle: mlflow.claude_code.tracing.process_transcript(path) + ours: ClaudeTranslator → Trace → observability.MLflowSink + +then the two resulting MLflow traces are compared structurally. This is the +"how does our product match up" check — it pins our foreign-trace output to the +autolog look (AGENT root + llm/tool children, anthropic message format, token +usage) that makes those traces navigable. + +Real mlflow logging to a local sqlite file — no network. Skips cleanly if the +maintained autolog parser isn't importable. +""" + +import json + +import pytest + +from dsagt.observability import MLflowSink +from dsagt.traces import ClaudeTranslator + +process_transcript = pytest.importorskip( + "mlflow.claude_code.tracing" +).process_transcript + + +def _asst(ts, *blocks, model="claude-sonnet-4-5", usage=None): + return { + "type": "assistant", + "timestamp": ts, + "message": { + "role": "assistant", + "model": model, + "content": list(blocks), + "usage": usage or {"input_tokens": 100, "output_tokens": 20}, + }, + } + + +def _user(ts, content, tool_use_result=False): + rec = { + "type": "user", + "timestamp": ts, + "message": {"role": "user", "content": content}, + } + if tool_use_result: + rec["toolUseResult"] = {"stdout": "a.db\nb.db"} + return rec + + +TRANSCRIPT = [ + _user("2026-06-19T15:49:29.000Z", "list the db files"), + _asst("2026-06-19T15:49:30.000Z", {"type": "thinking", "thinking": "let me think"}), + _asst( + "2026-06-19T15:49:31.000Z", + {"type": "text", "text": "Let me look at the files."}, + ), + _asst( + "2026-06-19T15:49:32.000Z", + { + "type": "tool_use", + "id": "tu1", + "name": "Bash", + "input": {"command": "ls *.db"}, + }, + ), + _user( + "2026-06-19T15:49:33.000Z", + [{"type": "tool_result", "tool_use_id": "tu1", "content": "a.db\nb.db"}], + tool_use_result=True, + ), + _asst( + "2026-06-19T15:49:35.000Z", + {"type": "text", "text": "Found two databases: a.db and b.db."}, + ), +] + + +def _span_shape(trace): + """(span_type, name) for every span, root first then children in start order.""" + spans = sorted(trace.data.spans, key=lambda s: s.start_time_ns) + return [(str(s.span_type), s.name) for s in spans] + + +def _llm_spans(trace): + return [s for s in trace.data.spans if str(s.span_type) == "LLM"] + + +def _durations_ns(trace): + """Per-span (end - start) in ns, ordered by start time.""" + spans = sorted(trace.data.spans, key=lambda s: s.start_time_ns) + return [s.end_time_ns - s.start_time_ns for s in spans] + + +@pytest.fixture +def mlflow_sqlite(tmp_path, monkeypatch): + """Point MLflow at an isolated sqlite store under tmp; chdir so the autolog + parser's ``.claude/mlflow`` log dir also lands in tmp.""" + import mlflow + + monkeypatch.chdir(tmp_path) + uri = f"sqlite:///{tmp_path / 'mlflow.db'}" + mlflow.set_tracking_uri(uri) + mlflow.set_experiment("parity") + return uri + + +def test_our_pipeline_matches_autolog_span_layout(tmp_path, mlflow_sqlite): + import mlflow + + # --- oracle: mlflow's maintained parser --- + path = tmp_path / "transcript.jsonl" + path.write_text("\n".join(json.dumps(r) for r in TRANSCRIPT)) + oracle = process_transcript(str(path), session_id="proj:sess1") + assert oracle is not None, "autolog parser produced no trace" + + # --- ours: translator → sink --- + trace = ClaudeTranslator().translate( + TRANSCRIPT, trace_id="tr1", session_id="proj:sess1", project="parity" + ) + (ours_id,) = MLflowSink(mlflow_sqlite, "parity").write( + trace + ) # one turn → one trace + ours = mlflow.get_trace(ours_id) + + # Same span tree: one AGENT root, two llm turns, one tool span — thinking + # turn produces no span in either. + assert _span_shape(ours) == _span_shape(oracle) + assert _span_shape(ours) == [ + ("AGENT", "claude_code_conversation"), + ("LLM", "llm"), + ("TOOL", "tool_Bash"), + ("LLM", "llm"), + ] + + # Span durations match the autolog model (ported #1): both derive each + # span's end from the next entry's timestamp, so the per-span durations line + # up — and crucially none is the absurd "now minus backdated start". + ours_durs = _durations_ns(ours) + oracle_durs = _durations_ns(oracle) + assert all(d > 0 for d in ours_durs) + for d_ours, d_oracle in zip(ours_durs, oracle_durs): + assert abs(d_ours - d_oracle) <= 2_000_000 # within 2ms (int-ns rounding) + + +def test_our_llm_spans_carry_the_navigable_attributes(mlflow_sqlite): + import mlflow + from mlflow.tracing.constant import SpanAttributeKey + + trace = ClaudeTranslator().translate( + TRANSCRIPT, trace_id="tr2", session_id="proj:sess1", project="parity" + ) + (tid,) = MLflowSink(mlflow_sqlite, "parity").write(trace) + ours = mlflow.get_trace(tid) + + llm = _llm_spans(ours) + assert llm, "expected llm spans" + for span in llm: + attrs = span.attributes + # the anthropic message-format flag is what triggers Chat-UI rendering + assert attrs.get(SpanAttributeKey.MESSAGE_FORMAT) == "anthropic" + # outputs are an anthropic assistant message + assert span.outputs["role"] == "assistant" + assert span.outputs["type"] == "message" + # token usage present + assert attrs.get(SpanAttributeKey.CHAT_USAGE)["input_tokens"] == 100 + + +def test_multi_turn_each_subtree_matches_autolog_on_its_slice(tmp_path, mlflow_sqlite): + """A 2-turn session → 2 of our MLflow traces; each must match what autolog + produces from that turn's slice in isolation.""" + import mlflow + + turn1 = [ + _user("2026-06-19T16:00:00.000Z", "first question"), + _asst("2026-06-19T16:00:01.000Z", {"type": "text", "text": "first answer"}), + ] + turn2 = [ + _user("2026-06-19T16:00:02.000Z", "second question"), + _asst( + "2026-06-19T16:00:03.000Z", + {"type": "tool_use", "id": "tu9", "name": "Bash", "input": {"c": "ls"}}, + ), + _user( + "2026-06-19T16:00:04.000Z", + [{"type": "tool_result", "tool_use_id": "tu9", "content": "ok"}], + tool_use_result=True, + ), + _asst("2026-06-19T16:00:05.000Z", {"type": "text", "text": "second answer"}), + ] + session = turn1 + turn2 + + # ours: one translate of the whole session → two traces, in turn order + trace = ClaudeTranslator().translate( + session, trace_id="trM", session_id="proj:sessM", project="parity" + ) + our_ids = MLflowSink(mlflow_sqlite, "parity").write(trace) + assert len(our_ids) == 2 + + # oracle: autolog on each turn's slice independently + for k, slice_records in enumerate([turn1, turn2]): + path = tmp_path / f"turn{k}.jsonl" + path.write_text("\n".join(json.dumps(r) for r in slice_records)) + oracle = process_transcript(str(path), session_id="proj:sessM") + assert oracle is not None + ours = mlflow.get_trace(our_ids[k]) + assert _span_shape(ours) == _span_shape(oracle) + + +def test_session_tag_and_canonical_id_on_trace(mlflow_sqlite): + import mlflow + from mlflow.tracing.constant import TraceMetadataKey + + trace = ClaudeTranslator().translate( + TRANSCRIPT, trace_id="tr3", session_id="proj:sess1", project="parity" + ) + (tid,) = MLflowSink(mlflow_sqlite, "parity").write(trace) + ours = mlflow.get_trace(tid) + md = ours.info.trace_metadata + assert md.get(TraceMetadataKey.TRACE_SESSION) == "proj:sess1" + # Per-turn idempotency key: :. + assert md.get("dsagt.trace_id").startswith("tr3:") + assert md.get("dsagt.agent") == "claude" diff --git a/tests/test_trace_scan.py b/tests/test_trace_scan.py new file mode 100644 index 0000000..52e2859 --- /dev/null +++ b/tests/test_trace_scan.py @@ -0,0 +1,236 @@ +"""Tests for the in-session trace heartbeat (TraceCollector). + +Exercise the collect logic directly (no event loop): the completeness watermark +(defer the open last turn) and ack-set idempotency, end-to-end through the real +ClaudeReader + ClaudeTranslator + MLflowSink into a tmp sqlite store. +""" + +import json + +import pytest + +from dsagt.traces import ( + Trace, + TraceCollector, + _transcript_dir, + make_trace_collector, +) + + +def _asst(ts, *blocks): + return { + "type": "assistant", + "timestamp": ts, + "message": { + "role": "assistant", + "model": "claude-x", + "content": list(blocks), + "usage": {"input_tokens": 5, "output_tokens": 2}, + }, + } + + +def _user(ts, content, uuid, tool_use_result=False): + rec = { + "type": "user", + "timestamp": ts, + "uuid": uuid, + "message": {"role": "user", "content": content}, + } + if tool_use_result: + rec["toolUseResult"] = {"stdout": "ok"} + return rec + + +def _append(path, *records): + with open(path, "a") as fh: + for r in records: + fh.write(json.dumps(r) + "\n") + + +@pytest.fixture +def scan_env(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + proj = tmp_path / "proj" + (proj / ".dsagt").mkdir(parents=True) + proot = tmp_path / "projects" + tdir = _transcript_dir(proj, proot) + tdir.mkdir(parents=True) + f = tdir / "sess.jsonl" + uri = f"sqlite:///{tmp_path / 'mlflow.db'}" + collector = make_trace_collector( + "claude", proj, "proj", "proj:s", uri, projects_root=proot + ) + return collector, f, proj + + +def test_make_trace_collector_registered_agents(tmp_path): + # The heartbeat isn't Claude-special: it runs for any agent with a pipeline. + for agent in ("claude", "codex", "goose", "opencode", "cline"): + assert ( + make_trace_collector(agent, tmp_path, "p", "p:s", "sqlite:///x.db") + is not None + ) + # An agent with no pipeline registered simply gets no heartbeat. + assert ( + make_trace_collector("nonesuch", tmp_path, "p", "p:s", "sqlite:///x.db") is None + ) + + +def test_subset_keeps_only_named_roots_and_their_children(): + trace = Trace("t", "s", "claude", "p") + trace.add_agent_root("r1", "claude_code_conversation", start_time=None, prompt="") + trace.add_llm_span( + "r1-0", parent_id="r1", start_time=None, end_time=None, request=[], response=[] + ) + trace.add_agent_root("r2", "claude_code_conversation", start_time=None, prompt="") + trace.add_llm_span( + "r2-0", parent_id="r2", start_time=None, end_time=None, request=[], response=[] + ) + sub = trace.subset({"r1"}) + assert [s["span_id"] for s in sub.spans] == ["r1", "r1-0"] + + +def test_periodic_collect_emits_complete_turns_and_defers_the_open_last(scan_env): + collector, f, _ = scan_env + # Two complete turns, then an open turn (prompt only). + _append( + f, + _user("2026-06-19T15:00:00.000Z", "q1", "u1"), + _asst("2026-06-19T15:00:01.000Z", {"type": "text", "text": "a1"}), + _user("2026-06-19T15:00:02.000Z", "q2", "u2"), + _asst("2026-06-19T15:00:03.000Z", {"type": "text", "text": "a2"}), + _user("2026-06-19T15:00:04.000Z", "q3 (still working)", "u3"), + ) + # roots = [u1, u2, u3]; periodic emits roots[:-1] = u1, u2; u3 deferred. + assert collector.collect() == 2 + assert collector._load_acks("mlflow") == {"u1", "u2"} + + +def test_collect_is_idempotent(scan_env): + collector, f, _ = scan_env + _append( + f, + _user("2026-06-19T15:00:00.000Z", "q1", "u1"), + _asst("2026-06-19T15:00:01.000Z", {"type": "text", "text": "a1"}), + _user("2026-06-19T15:00:02.000Z", "q2", "u2"), + ) + assert collector.collect() == 1 # u1 emitted, u2 (last) deferred + assert collector.collect() == 0 # nothing new + assert collector.collect() == 0 + + +def test_deferred_turn_emits_once_a_later_prompt_bounds_it(scan_env): + collector, f, _ = scan_env + _append( + f, + _user("2026-06-19T15:00:00.000Z", "q1", "u1"), + _asst("2026-06-19T15:00:01.000Z", {"type": "text", "text": "a1"}), + _user("2026-06-19T15:00:02.000Z", "q2", "u2"), + ) + assert collector.collect() == 1 # u1 only + # A new prompt arrives → u2 is no longer the open turn. + _append( + f, + _asst("2026-06-19T15:00:03.000Z", {"type": "text", "text": "a2"}), + _user("2026-06-19T15:00:04.000Z", "q3", "u3"), + ) + assert collector.collect() == 1 # u2 now emitted; u3 deferred + assert collector._load_acks("mlflow") == {"u1", "u2"} + + +def test_final_flush_emits_the_deferred_last_turn(scan_env): + collector, f, _ = scan_env + _append( + f, + _user("2026-06-19T15:00:00.000Z", "q1", "u1"), + _asst("2026-06-19T15:00:01.000Z", {"type": "text", "text": "a1"}), + _user("2026-06-19T15:00:02.000Z", "q2", "u2"), + _asst("2026-06-19T15:00:03.000Z", {"type": "text", "text": "a2"}), + ) + assert collector.collect() == 1 # u1; u2 deferred + assert collector.collect(include_last=True) == 1 # end-of-session flush emits u2 + assert collector.collect(include_last=True) == 0 # idempotent + assert collector._load_acks("mlflow") == {"u1", "u2"} + + +def test_collect_on_empty_transcript_is_zero(scan_env): + collector, f, _ = scan_env + f.write_text("") + assert collector.collect() == 0 + assert collector.collect(include_last=True) == 0 + + +class _Recorder: + def __init__(self, name, fail=False): + self.name = name + self.fail = fail + self.seen = [] + + def write(self, trace): + if self.fail: + raise RuntimeError("consumer down") + self.seen.append({s["span_id"] for s in trace.spans if s["parent_id"] is None}) + + +class _FakeReader: + def read(self): + return [{"x": 1}] # non-empty so collect proceeds; translator ignores + + +class _FakeTranslator: + def __init__(self, trace): + self._trace = trace + + def translate(self, records, **kw): + return self._trace + + +def test_consumers_ack_independently(tmp_path): + # Two AGENT roots → two complete turns (include_last emits both). + trace = Trace("t", "p:s", "claude", "p") + trace.add_agent_root("r1", "c", start_time=None, prompt="") + trace.add_agent_root("r2", "c", start_time=None, prompt="") + good = _Recorder("good") + bad = _Recorder("bad", fail=True) + collector = TraceCollector( + _FakeReader(), + _FakeTranslator(trace), + project="p", + session_id="p:s", + project_dir=tmp_path, + consumers=[good, bad], + ) + + # One turn advanced for at least one consumer → returns 2 (both turns new + # to the good consumer); the failing one logs and holds its mark back. + assert collector.collect(include_last=True) == 2 + assert good.seen == [{"r1", "r2"}] + assert collector._load_acks("good") == {"r1", "r2"} + assert collector._load_acks("bad") == set() # wedged consumer didn't advance + + # Good is fully caught up; bad retries (and fails again) — good doesn't redo. + assert collector.collect(include_last=True) == 0 + assert good.seen == [{"r1", "r2"}] # not re-delivered + assert collector._load_acks("bad") == set() + + +def test_emitted_traces_land_in_the_store(scan_env): + import mlflow + + collector, f, _ = scan_env + _append( + f, + _user("2026-06-19T15:00:00.000Z", "q1", "u1"), + _asst("2026-06-19T15:00:01.000Z", {"type": "text", "text": "a1"}), + _user("2026-06-19T15:00:02.000Z", "q2", "u2"), + _asst("2026-06-19T15:00:03.000Z", {"type": "text", "text": "a2"}), + ) + collector.collect(include_last=True) # emit both turns + exp = mlflow.get_experiment_by_name("proj") + traces = mlflow.search_traces( + experiment_ids=[exp.experiment_id], return_type="list" + ) + sessions = {t.info.trace_metadata.get("mlflow.trace.session") for t in traces} + assert len(traces) == 2 + assert sessions == {"proj:s"} diff --git a/tests/test_traces.py b/tests/test_traces.py new file mode 100644 index 0000000..2a43155 --- /dev/null +++ b/tests/test_traces.py @@ -0,0 +1,436 @@ +"""Fixture tests for the canonical trace waist + Claude translator (Phase 2). + +Pure, no disk / no network. Two things under test: +- ``to_exchanges`` projects LLM spans onto the conversational shape the existing + Phase-3 extractor (``memory.build_extraction_prompt``) consumes — the seam. +- ``ClaudeTranslator`` turns raw transcript records into a ``Trace`` + with the autolog-style AGENT/LLM/TOOL span layout. +""" + +import json + +from dsagt.memory import build_extraction_prompt +from dsagt.traces import ( + ClaudeReader, + ClaudeTranslator, + Trace, + _transcript_dir, +) + +# --------------------------------------------------------------------------- +# to_exchanges projection (windowed model) +# --------------------------------------------------------------------------- + + +def _windowed_trace() -> Trace: + """Two LLM spans whose ``request`` already holds the per-turn window.""" + trace = Trace( + trace_id="tr1", session_id="proj:sess1", agent="claude", project="proj" + ) + trace.add_llm_span( + "s1", + parent_id="r1", + start_time=100.0, + end_time=None, + request=[ + {"role": "user", "content": [{"type": "text", "text": "Profile sales.csv"}]} + ], + response=[ + { + "type": "tool_use", + "id": None, + "name": "profile", + "input": {"path": "sales.csv"}, + } + ], + ) + trace.add_tool_span( + "s3", + parent_id="r1", + start_time=101.0, + end_time=None, + name="profile", + tool_input={"path": "sales.csv"}, + result="1000 rows", + ) + trace.add_llm_span( + "s2", + parent_id="r1", + start_time=102.0, + end_time=None, + request=[ + { + "role": "user", + "content": [ + {"type": "tool_result", "tool_use_id": "t1", "content": "1000 rows"} + ], + } + ], + response=[{"type": "text", "text": "The file has 1000 rows."}], + ) + return trace + + +def test_to_exchanges_one_per_llm_span_skipping_tool_spans(): + exchanges = _windowed_trace().to_exchanges() + assert len(exchanges) == 2 + assert [e["timestamp"] for e in exchanges] == [100.0, 102.0] + + +def test_to_exchanges_uses_request_window_directly(): + ex1, ex2 = _windowed_trace().to_exchanges() + assert [m["role"] for m in ex1["new_messages"]] == ["user"] + # The tool_result block round-trips in Anthropic shape memory can read. + block = ex2["new_messages"][0]["content"][0] + assert block == {"type": "tool_result", "tool_use_id": "t1", "content": "1000 rows"} + assert ex2["response"] == [{"type": "text", "text": "The file has 1000 rows."}] + + +def test_projection_feeds_the_real_extraction_prompt(): + """The waist's whole justification: exchanges render through the existing + Phase-3 prompt builder with no adapter, carrying the real content.""" + prompt = build_extraction_prompt(_windowed_trace().to_exchanges()) + assert "Profile sales.csv" in prompt + assert "profile" in prompt # tool_use name (in the assistant response) + assert "1000 rows" in prompt # tool_result, via _extract_block_text + assert "The file has 1000 rows." in prompt # final answer + + +def test_none_tolerant_usage_and_timing(): + trace = Trace(trace_id="t", session_id="s", agent="goose", project="p") + span = trace.add_llm_span( + "s1", parent_id="r1", start_time=None, end_time=None, request=[], response=[] + ) + assert span["usage"] is None and span["start_time"] is None + assert trace.to_exchanges() == [ + {"timestamp": None, "new_messages": [], "response": []} + ] + + +# --------------------------------------------------------------------------- +# ClaudeTranslator +# --------------------------------------------------------------------------- + + +def _asst(ts, *blocks, model="claude-x", usage=None): + return { + "type": "assistant", + "timestamp": ts, + "message": { + "role": "assistant", + "model": model, + "content": list(blocks), + "usage": usage or {"input_tokens": 10, "output_tokens": 3}, + }, + } + + +def _user(ts, content, tool_use_result=False): + rec = { + "type": "user", + "timestamp": ts, + "message": {"role": "user", "content": content}, + } + if tool_use_result: + rec["toolUseResult"] = {"stdout": "..."} + return rec + + +def _single_turn_records(): + """user → thinking → text → tool_use → tool_result → text (final).""" + return [ + _user("2026-06-19T15:49:29.000Z", "list the db files"), + _asst("2026-06-19T15:49:30.000Z", {"type": "thinking", "thinking": "hmm"}), + _asst("2026-06-19T15:49:31.000Z", {"type": "text", "text": "Let me look."}), + _asst( + "2026-06-19T15:49:32.000Z", + {"type": "tool_use", "id": "tu1", "name": "Bash", "input": {"cmd": "ls"}}, + ), + _user( + "2026-06-19T15:49:33.000Z", + [{"type": "tool_result", "tool_use_id": "tu1", "content": "a.db\nb.db"}], + tool_use_result=True, + ), + _asst( + "2026-06-19T15:49:34.000Z", {"type": "text", "text": "Found a.db and b.db."} + ), + ] + + +def _translate(records): + return ClaudeTranslator().translate( + records, trace_id="tr", session_id="proj:s", project="proj" + ) + + +def test_translate_builds_agent_root_with_prompt_and_response(): + trace = _translate(_single_turn_records()) + root = trace.spans[0] + assert root["kind"] == "AGENT" + assert root["name"] == "claude_code_conversation" + assert root["attributes"]["prompt"] == "list the db files" + assert root["attributes"]["response"] == "Found a.db and b.db." + assert trace.started_at is not None and trace.ended_at is not None + + +def test_translate_span_layout_matches_autolog_shape(): + trace = _translate(_single_turn_records()) + kinds = [(s["kind"], s["name"]) for s in trace.spans] + # thinking turn produces NO span; two text turns → 2 LLM; one tool_use → 1 TOOL. + assert kinds == [ + ("AGENT", "claude_code_conversation"), + ("LLM", "llm"), + ("TOOL", "tool_Bash"), + ("LLM", "llm"), + ] + + +def test_translate_llm_span_carries_model_usage_and_window(): + trace = _translate(_single_turn_records()) + llms = [s for s in trace.spans if s["kind"] == "LLM"] + first, second = llms + assert first["model"] == "claude-x" + assert first["usage"]["input_tokens"] == 10 and first["usage"]["output_tokens"] == 3 + # The final LLM turn's window is the tool_use + tool_result run since the + # previous text turn (the "Let me look." boundary is excluded). + roles = [m["role"] for m in second["request"]] + assert "user" in roles # the tool_result message + assert second["response"][0]["text"] == "Found a.db and b.db." + + +def test_translate_tool_span_has_input_and_result(): + trace = _translate(_single_turn_records()) + tool = next(s for s in trace.spans if s["kind"] == "TOOL") + assert tool["attributes"]["tool_name"] == "Bash" + assert tool["attributes"]["input"] == {"cmd": "ls"} + assert tool["attributes"]["result"] == "a.db\nb.db" + assert tool["parent_id"] == trace.spans[0]["span_id"] + + +def test_translate_empty_transcript_is_none(): + assert _translate([]) is None + + +# --------------------------------------------------------------------------- +# Ported edge cases (from mlflow's claude_code parser) +# --------------------------------------------------------------------------- + + +def test_spans_get_real_durations_not_a_now_end(): + """#1 — every span ends via the next-timestamp model, never an open end_time + (which the sink would otherwise stamp as wall-clock now → absurd duration).""" + trace = _translate(_single_turn_records()) + for span in trace.spans: + assert span["start_time"] is not None and span["end_time"] is not None + assert span["end_time"] > span["start_time"] + # Final LLM turn has no following entry → 1s fallback, not a huge span. + final_llm = [s for s in trace.spans if s["kind"] == "LLM"][-1] + assert final_llm["end_time"] - final_llm["start_time"] == 1.0 + + +def test_skill_injection_user_message_is_not_taken_as_the_prompt(): + """#2 — a user entry following a Skill tool result (commandName) is an + injection, not the human prompt; the real prompt is selected instead.""" + records = [ + _user("2026-06-19T15:00:00.000Z", "real prompt"), + _asst("2026-06-19T15:00:01.000Z", {"type": "text", "text": "answer"}), + { + "type": "user", + "timestamp": "2026-06-19T15:00:02.000Z", + "toolUseResult": {"commandName": "some-skill"}, + "message": {"role": "user", "content": "skill tool result"}, + }, + _user("2026-06-19T15:00:03.000Z", "injected skill content"), + ] + trace = _translate(records) + assert trace.spans[0]["attributes"]["prompt"] == "real prompt" + + +def test_local_command_stdout_is_not_taken_as_the_prompt(): + """#2 — slash-command stdout echoes are skipped when finding the prompt.""" + records = [ + _user("2026-06-19T15:00:00.000Z", "real prompt"), + _asst("2026-06-19T15:00:01.000Z", {"type": "text", "text": "answer"}), + _user( + "2026-06-19T15:00:02.000Z", + "ran /foo", + ), + ] + trace = _translate(records) + assert trace.spans[0]["attributes"]["prompt"] == "real prompt" + + +def test_steer_message_folds_into_the_llm_window(): + """#3 — a queue-operation/enqueue steer message becomes a user message in + the following text turn's input window.""" + records = [ + _user("2026-06-19T15:00:00.000Z", "start a long task"), + _asst( + "2026-06-19T15:00:01.000Z", + {"type": "tool_use", "id": "tu1", "name": "Bash", "input": {"c": "sleep"}}, + ), + { + "type": "queue-operation", + "operation": "enqueue", + "timestamp": "2026-06-19T15:00:02.000Z", + "content": "actually, stop and summarize", + }, + _user( + "2026-06-19T15:00:03.000Z", + [{"type": "tool_result", "tool_use_id": "tu1", "content": "done"}], + tool_use_result=True, + ), + _asst("2026-06-19T15:00:04.000Z", {"type": "text", "text": "Summary."}), + ] + trace = _translate(records) + final_llm = [s for s in trace.spans if s["kind"] == "LLM"][-1] + texts = [ + b["text"] + for m in final_llm["request"] + for b in m["content"] + if b["type"] == "text" + ] + assert "actually, stop and summarize" in texts + + +# --------------------------------------------------------------------------- +# Multi-turn segmentation +# --------------------------------------------------------------------------- + + +def _two_turn_records(): + return [ + _user("2026-06-19T15:00:00.000Z", "first question"), + _asst("2026-06-19T15:00:01.000Z", {"type": "text", "text": "first answer"}), + _user("2026-06-19T15:00:02.000Z", "second question"), + _asst( + "2026-06-19T15:00:03.000Z", + {"type": "tool_use", "id": "t", "name": "Bash", "input": {"c": "ls"}}, + ), + _user( + "2026-06-19T15:00:04.000Z", + [{"type": "tool_result", "tool_use_id": "t", "content": "ok"}], + tool_use_result=True, + ), + _asst("2026-06-19T15:00:05.000Z", {"type": "text", "text": "second answer"}), + ] + + +def test_translate_segments_one_agent_subtree_per_turn(): + trace = _translate(_two_turn_records()) + roots = [s for s in trace.spans if s["parent_id"] is None] + assert len(roots) == 2 + assert roots[0]["attributes"]["prompt"] == "first question" + assert roots[1]["attributes"]["prompt"] == "second question" + # Turn 2's tool + final-answer LLM hang off turn 2's root, not turn 1's. + t2 = [s for s in trace.spans if s["parent_id"] == roots[1]["span_id"]] + assert {s["kind"] for s in t2} == {"LLM", "TOOL"} + assert roots[1]["attributes"]["response"] == "second answer" + + +def test_each_turn_is_self_contained_no_cross_turn_duration_borrow(): + trace = _translate(_two_turn_records()) + roots = [s for s in trace.spans if s["parent_id"] is None] + # Turn 1's only LLM span is its last → 1s fallback, NOT the 2s gap to turn 2. + t1_llm = next( + s + for s in trace.spans + if s["parent_id"] == roots[0]["span_id"] and s["kind"] == "LLM" + ) + assert t1_llm["end_time"] - t1_llm["start_time"] == 1.0 + + +def test_translate_skips_a_leading_partial_turn(): + """Records before the first prompt in the batch belong to an already-processed + turn (incremental read) — they produce no subtree, which is what keeps + cursor-driven reads idempotent at turn granularity.""" + records = [ + _asst( + "2026-06-19T15:00:00.000Z", {"type": "text", "text": "tail of prev turn"} + ), + _user("2026-06-19T15:00:01.000Z", "new prompt"), + _asst("2026-06-19T15:00:02.000Z", {"type": "text", "text": "answer"}), + ] + trace = _translate(records) + roots = [s for s in trace.spans if s["parent_id"] is None] + assert len(roots) == 1 + assert roots[0]["attributes"]["prompt"] == "new prompt" + + +# --------------------------------------------------------------------------- +# ClaudeReader — whole-file read +# --------------------------------------------------------------------------- + + +def _write_lines(path, objs, mode="w"): + with open(path, mode) as fh: + for o in objs: + fh.write(json.dumps(o) + "\n") + + +def test_reader_reads_the_whole_active_file(tmp_path): + proj = tmp_path / "proj" + proj.mkdir() + root = tmp_path / "projects" + tdir = _transcript_dir(proj, root) + tdir.mkdir(parents=True) + f = tdir / "sess.jsonl" + _write_lines(f, [{"a": 1}, {"a": 2}]) + + reader = ClaudeReader(proj, projects_root=root) + recs = reader.read() + assert [r["a"] for r in recs] == [1, 2] + + _write_lines(f, [{"a": 3}], mode="a") + recs2 = reader.read() + assert [r["a"] for r in recs2] == [1, 2, 3] # re-reads the whole file + + +def test_reader_leaves_a_partial_trailing_line(tmp_path): + proj = tmp_path / "proj" + proj.mkdir() + root = tmp_path / "projects" + tdir = _transcript_dir(proj, root) + tdir.mkdir(parents=True) + f = tdir / "sess.jsonl" + _write_lines(f, [{"a": 1}]) + + reader = ClaudeReader(proj, projects_root=root) + assert [r["a"] for r in reader.read()] == [1] + + # A half-written append (no newline) must not be parsed. + with open(f, "a") as fh: + fh.write('{"a": 2') + assert [r["a"] for r in reader.read()] == [1] + + # Once the line completes, it's read. + with open(f, "a") as fh: + fh.write("}\n") + assert [r["a"] for r in reader.read()] == [1, 2] + + +def test_reader_picks_the_most_recent_session(tmp_path): + proj = tmp_path / "proj" + proj.mkdir() + root = tmp_path / "projects" + tdir = _transcript_dir(proj, root) + tdir.mkdir(parents=True) + old = tdir / "old.jsonl" + _write_lines(old, [{"a": 1}, {"a": 2}]) + + # A newer session file becomes active; the reader follows the newest mtime. + import os + + new = tdir / "new.jsonl" + _write_lines(new, [{"b": 1}]) + newer = old.stat().st_mtime + 10 + os.utime(new, (newer, newer)) # ensure new is the most-recent file + reader = ClaudeReader(proj, projects_root=root) + assert [r.get("b") for r in reader.read()] == [1] + + +def test_reader_no_transcripts_is_empty(tmp_path): + proj = tmp_path / "proj" + proj.mkdir() + reader = ClaudeReader(proj, projects_root=tmp_path / "projects") + assert reader.read() == [] diff --git a/tests/test_traces_cline.py b/tests/test_traces_cline.py new file mode 100644 index 0000000..e93de50 --- /dev/null +++ b/tests/test_traces_cline.py @@ -0,0 +1,169 @@ +"""Fixture tests for the Cline CLI translator + reader. + +The text path mirrors real ``~/.cline`` sessions (validated in discovery); the +tool path uses standard Anthropic blocks (what Cline's SDK emits) since the +captured sessions were text-only. +""" + +import json + +import pytest + +from dsagt.observability import MLflowSink +from dsagt.traces import ClineReader, ClineTranslator + + +def _user(ts, text): + return { + "id": f"u{ts}", + "role": "user", + "ts": ts, + "content": [{"type": "text", "text": text}], + } + + +def _asst(ts, text=None, tools=(), model=None): + content = [] + if text is not None: + content.append({"type": "text", "text": text}) + for name, inp, tid in tools: + content.append({"type": "tool_use", "id": tid, "name": name, "input": inp}) + return { + "id": f"a{ts}", + "role": "assistant", + "ts": ts, + "content": content, + "model": model, + } + + +def _toolresult(ts, tid, result): + return { + "id": f"t{ts}", + "role": "user", + "ts": ts, + "content": [{"type": "tool_result", "tool_use_id": tid, "content": result}], + } + + +def _session(): + return [ + _user(1_000_000, 'do the thing'), # turn 1 + _asst( + 1_001_000, + "Let me run a command.", + tools=[("shell", {"cmd": "ls"}, "t1")], + model="m", + ), + _toolresult(1_002_000, "t1", "a.txt"), # tool result (user msg, not a prompt) + _asst(1_003_000, "Done.", model="m"), + _user(1_004_000, "next"), # turn 2 + _asst(1_005_000, "OK.", model="m"), + ] + + +def _translate(records=None): + return ClineTranslator().translate( + records if records is not None else _session(), + trace_id="cl", + session_id="proj:s", + project="clineproj", + ) + + +def test_user_input_wrapper_stripped_and_turns_segmented(): + roots = [s for s in _translate().spans if s["parent_id"] is None] + assert len(roots) == 2 # the tool_result user message is not a turn + assert roots[0]["attributes"]["prompt"] == "do the thing" # unwrapped + assert roots[1]["attributes"]["prompt"] == "next" + + +def test_assistant_text_and_tool_use_in_one_message(): + trace = _translate() + roots = [s for s in trace.spans if s["parent_id"] is None] + t1 = [ + (s["kind"], s["name"]) + for s in trace.spans + if s["parent_id"] == roots[0]["span_id"] + ] + # message 1 has text + tool_use → an LLM span then a TOOL span; then "Done." LLM. + assert t1 == [ + ("LLM", "llm"), + ("TOOL", "tool_shell"), + ("LLM", "llm"), + ] + tool = next(s for s in trace.spans if s["kind"] == "TOOL") + assert tool["attributes"]["input"] == {"cmd": "ls"} + assert tool["attributes"]["result"] == "a.txt" + assert roots[0]["attributes"]["response"] == "Done." + assert trace.spans[1]["model"] == "m" # first LLM carries the session model + + +def test_tool_result_as_text_block_list(): + records = [ + _user(1, "go"), + _asst(2, "calling", tools=[("t", {}, "x1")]), + _toolresult(3, "x1", [{"type": "text", "text": "block result"}]), + _asst(4, "ok"), + ] + tool = next(s for s in _translate(records).spans if s["kind"] == "TOOL") + assert tool["attributes"]["result"] == "block result" + + +def test_empty_is_none(): + assert _translate([]) is None + assert _translate([_asst(1, "no user prompt")]) is None + + +# --- reader over a tiny ~/.cline/data/sessions tree --- + + +def _make_session(root, sid, cwd, messages, model="m"): + d = root / sid + d.mkdir(parents=True) + (d / f"{sid}.json").write_text(json.dumps({"cwd": cwd, "model": model})) + (d / f"{sid}.messages.json").write_text(json.dumps({"messages": messages})) + + +def test_reader_picks_newest_session_for_the_cwd(tmp_path): + root = tmp_path / "sessions" + _make_session(root, "200_p1", "/proj", [_user(1, "old")]) + _make_session(root, "300_p2", "/proj", [_user(2, "new")]) + _make_session( + root, "400_x", "/elsewhere", [_user(3, "other")] + ) # newest overall, wrong cwd + reader = ClineReader("/proj", sessions_root=root) + assert reader._active_dir().name == "300_p2" + records = reader.read() + assert records[0]["content"][0]["text"] == "new" + assert records[0]["model"] == "m" # attached from metadata + + +def test_reader_no_match_is_empty(tmp_path): + root = tmp_path / "sessions" + _make_session(root, "100_a", "/elsewhere", [_user(1, "x")]) + records = ClineReader("/proj", sessions_root=root).read() + assert records == [] + + +@pytest.fixture +def mlflow_sqlite(tmp_path, monkeypatch): + import mlflow + + monkeypatch.chdir(tmp_path) + uri = f"sqlite:///{tmp_path / 'mlflow.db'}" + mlflow.set_tracking_uri(uri) + mlflow.set_experiment("clineproj") + return uri + + +def test_end_to_end_through_the_sink(mlflow_sqlite): + import mlflow + + ids = MLflowSink(mlflow_sqlite, "clineproj").write(_translate()) + assert len(ids) == 2 + exp = mlflow.get_experiment_by_name("clineproj") + traces = mlflow.search_traces( + experiment_ids=[exp.experiment_id], return_type="list" + ) + assert len(traces) == 2 diff --git a/tests/test_traces_codex.py b/tests/test_traces_codex.py new file mode 100644 index 0000000..e1da15e --- /dev/null +++ b/tests/test_traces_codex.py @@ -0,0 +1,212 @@ +"""Fixture tests for the Codex rollout translator. + +Synthetic but grammar-faithful records (validated against a real rollout in +discovery): developer/AGENTS context, the real prompt, reasoning, assistant +text, function_call + custom_tool_call with their outputs, across two turns. +""" + +import json + +import pytest + +from dsagt.observability import MLflowSink +from dsagt.traces import CodexReader, CodexTranslator + + +def _rec(ts, rtype, payload): + return {"timestamp": ts, "type": rtype, "payload": payload} + + +def _msg(ts, role, *texts): + bt = "output_text" if role == "assistant" else "input_text" + return _rec( + ts, + "response_item", + { + "type": "message", + "role": role, + "content": [{"type": bt, "text": t} for t in texts], + }, + ) + + +def _fcall(ts, name, args, cid): + return _rec( + ts, + "response_item", + { + "type": "function_call", + "name": name, + "arguments": json.dumps(args), + "call_id": cid, + }, + ) + + +def _fout(ts, cid, out): + return _rec( + ts, + "response_item", + {"type": "function_call_output", "call_id": cid, "output": out}, + ) + + +def _custom(ts, name, raw_input, cid): + return _rec( + ts, + "response_item", + {"type": "custom_tool_call", "name": name, "input": raw_input, "call_id": cid}, + ) + + +def _custom_out(ts, cid, out): + return _rec( + ts, + "response_item", + {"type": "custom_tool_call_output", "call_id": cid, "output": out}, + ) + + +def _reasoning(ts): + return _rec( + ts, + "response_item", + {"type": "reasoning", "summary": [], "encrypted_content": "x"}, + ) + + +def _session(): + T = "2026-06-18T21:30:%02d.000Z" + return [ + _rec(T % 0, "session_meta", {"id": "s", "cwd": "/p"}), + _msg(T % 1, "developer", "system instructions"), + _msg(T % 2, "user", "# AGENTS.md instructions for /p"), # injected context + _msg(T % 3, "user", "fix the hang"), # the real prompt (turn 1) + _reasoning(T % 4), # skipped + _msg(T % 5, "assistant", "Let me look."), # LLM + _fcall(T % 6, "exec_command", {"cmd": "ls"}, "c1"), # TOOL + _fout(T % 7, "c1", "a.py\nb.py"), + _custom(T % 8, "apply_patch", "*** Begin Patch ...", "c2"), # TOOL (raw input) + _custom_out(T % 9, "c2", "Success."), + _msg(T % 10, "assistant", "Fixed it."), # LLM (final of turn 1) + _msg(T % 11, "user", "now run the tests"), # turn 2 prompt + _msg(T % 12, "assistant", "Tests pass."), # LLM + ] + + +def _translate(records=None): + return CodexTranslator().translate( + records if records is not None else _session(), + trace_id="cx", + session_id="proj:s", + project="codexproj", + ) + + +def test_agents_md_injection_is_not_the_prompt(): + roots = [s for s in _translate().spans if s["parent_id"] is None] + assert len(roots) == 2 + assert roots[0]["attributes"]["prompt"] == "fix the hang" # not the AGENTS.md msg + assert roots[1]["attributes"]["prompt"] == "now run the tests" + + +def test_turn1_span_layout_skips_reasoning(): + trace = _translate() + roots = [s for s in trace.spans if s["parent_id"] is None] + t1 = [ + (s["kind"], s["name"]) + for s in trace.spans + if s["parent_id"] == roots[0]["span_id"] + ] + # reasoning → no span; 2 assistant texts → 2 LLM; 2 tool calls → 2 TOOL. + assert t1 == [ + ("LLM", "llm"), + ("TOOL", "tool_exec_command"), + ("TOOL", "tool_apply_patch"), + ("LLM", "llm"), + ] + assert roots[0]["attributes"]["response"] == "Fixed it." + + +def test_tool_input_parsed_and_result_matched_by_call_id(): + trace = _translate() + by_name = {s["name"]: s for s in trace.spans if s["kind"] == "TOOL"} + exec_span = by_name["tool_exec_command"] + assert exec_span["attributes"]["input"] == {"cmd": "ls"} # JSON args parsed + assert exec_span["attributes"]["result"] == "a.py\nb.py" + patch_span = by_name["tool_apply_patch"] + assert ( + patch_span["attributes"]["input"] == "*** Begin Patch ..." + ) # raw custom input + assert patch_span["attributes"]["result"] == "Success." + + +def test_durations_are_bounded_per_turn(): + trace = _translate() + for s in trace.spans: + if s["parent_id"] is not None: + assert s["end_time"] is not None and s["end_time"] > s["start_time"] + # turn 1's final LLM is bounded by turn 2's prompt? No — turns are + # independent, so its last span falls back to 1s, not the gap to turn 2. + roots = [s for s in trace.spans if s["parent_id"] is None] + t1_last_llm = [ + s + for s in trace.spans + if s["parent_id"] == roots[0]["span_id"] and s["kind"] == "LLM" + ][-1] + assert t1_last_llm["end_time"] - t1_last_llm["start_time"] == 1.0 + + +def test_empty_records_is_none(): + assert _translate([]) is None + assert _translate([{"type": "event_msg", "payload": {}}]) is None + + +def test_reader_picks_the_rollout_matching_the_project_cwd(tmp_path): + root = tmp_path / "sessions" / "2026" / "06" / "18" + root.mkdir(parents=True) + # Two rollouts; only one's session_meta.cwd matches the project dir. + mine = root / "rollout-mine.jsonl" + mine.write_text("\n".join(json.dumps(r) for r in _session())) + other = root / "rollout-other.jsonl" + other.write_text( + json.dumps(_rec("t", "session_meta", {"id": "o", "cwd": "/elsewhere"})) + "\n" + ) + reader = CodexReader("/p", sessions_root=tmp_path / "sessions") + # _session()'s session_meta cwd is "/p". + assert reader.active_file() == mine + records = reader.read() + assert any(r.get("type") == "response_item" for r in records) + + +def test_reader_no_match_is_empty(tmp_path): + (tmp_path / "sessions").mkdir() + records = CodexReader("/p", sessions_root=tmp_path / "sessions").read() + assert records == [] + + +@pytest.fixture +def mlflow_sqlite(tmp_path, monkeypatch): + import mlflow + + monkeypatch.chdir(tmp_path) + uri = f"sqlite:///{tmp_path / 'mlflow.db'}" + mlflow.set_tracking_uri(uri) + mlflow.set_experiment("codexproj") + return uri + + +def test_end_to_end_through_the_sink(mlflow_sqlite): + import mlflow + + trace = _translate() + ids = MLflowSink(mlflow_sqlite, "codexproj").write(trace) + assert len(ids) == 2 # one MLflow trace per turn + exp = mlflow.get_experiment_by_name("codexproj") + traces = mlflow.search_traces( + experiment_ids=[exp.experiment_id], return_type="list" + ) + assert len(traces) == 2 + # the tool-bearing turn rendered llm + tool spans under its agent root + shapes = {len(t.data.spans) for t in traces} + assert 5 in shapes # root + 2 llm + 2 tool diff --git a/tests/test_traces_goose.py b/tests/test_traces_goose.py new file mode 100644 index 0000000..be04a91 --- /dev/null +++ b/tests/test_traces_goose.py @@ -0,0 +1,167 @@ +"""Fixture tests for the Goose SQLite translator + reader. + +Translator records mirror what ``GooseReader`` yields (``{role, content, ts}`` +with parsed content_json blocks); the reader test builds a tiny sessions.db. +""" + +import sqlite3 + +import pytest + +from dsagt.observability import MLflowSink +from dsagt.traces import GooseReader, GooseTranslator + + +def _utext(ts, text): + return {"role": "user", "ts": ts, "content": [{"type": "text", "text": text}]} + + +def _atext(ts, text): + return {"role": "assistant", "ts": ts, "content": [{"type": "text", "text": text}]} + + +def _atool(ts, name, args, tid): + return { + "role": "assistant", + "ts": ts, + "content": [ + { + "type": "toolRequest", + "id": tid, + "toolCall": {"value": {"name": name, "arguments": args}}, + } + ], + } + + +def _uresp(ts, tid, text): + return { + "role": "user", + "ts": ts, + "content": [ + { + "type": "toolResponse", + "id": tid, + "toolResult": {"value": {"content": [{"type": "text", "text": text}]}}, + } + ], + } + + +def _session(): + return [ + _utext(1000, "do the thing"), # turn 1 prompt + _atext(1001, "Let me check."), # LLM + _atool(1002, "shell", {"command": "ls"}, "t1"), # TOOL + _uresp(1003, "t1", "a.txt\nb.txt"), # result (user msg, not a prompt) + _atext(1004, "Done."), # LLM + _utext(1005, "now another"), # turn 2 prompt + _atext(1006, "OK."), # LLM + ] + + +def _translate(records=None): + return GooseTranslator().translate( + records if records is not None else _session(), + trace_id="g", + session_id="proj:s", + project="gooseproj", + ) + + +def test_turns_segment_on_user_text_not_tool_responses(): + roots = [s for s in _translate().spans if s["parent_id"] is None] + assert len(roots) == 2 # the toolResponse user message is NOT a turn + assert roots[0]["attributes"]["prompt"] == "do the thing" + assert roots[1]["attributes"]["prompt"] == "now another" + + +def test_span_layout_and_tool_extraction(): + trace = _translate() + roots = [s for s in trace.spans if s["parent_id"] is None] + t1 = [ + (s["kind"], s["name"]) + for s in trace.spans + if s["parent_id"] == roots[0]["span_id"] + ] + assert t1 == [ + ("LLM", "llm"), + ("TOOL", "tool_shell"), + ("LLM", "llm"), + ] + tool = next(s for s in trace.spans if s["kind"] == "TOOL") + assert tool["attributes"]["input"] == {"command": "ls"} + assert ( + tool["attributes"]["result"] == "a.txt\nb.txt" + ) # toolResult.value.content text + assert roots[0]["attributes"]["response"] == "Done." + + +def test_empty_is_none(): + assert _translate([]) is None + assert _translate([_atext(1, "no user prompt")]) is None + + +# --- reader over a tiny sessions.db --- + + +def _make_db(path): + con = sqlite3.connect(path) + con.execute("CREATE TABLE sessions(id TEXT, working_dir TEXT, updated_at INTEGER)") + con.execute( + "CREATE TABLE messages(id INTEGER PRIMARY KEY, session_id TEXT, " + "role TEXT, content_json TEXT, created_timestamp INTEGER)" + ) + con.execute("INSERT INTO sessions VALUES('s_old', '/proj', 100)") + con.execute( + "INSERT INTO sessions VALUES('s_new', '/proj', 200)" + ) # newest for /proj + con.execute("INSERT INTO sessions VALUES('s_other', '/elsewhere', 300)") + rows = [ + ("s_new", "user", '[{"type":"text","text":"hi"}]', 1000), + ("s_new", "assistant", '[{"type":"text","text":"hello"}]', 1001), + ("s_old", "user", '[{"type":"text","text":"old session"}]', 50), + ("s_other", "user", '[{"type":"text","text":"other dir"}]', 5), + ] + con.executemany( + "INSERT INTO messages(session_id, role, content_json, created_timestamp) " + "VALUES(?,?,?,?)", + rows, + ) + con.commit() + con.close() + + +def test_reader_picks_newest_session_for_the_project_dir(tmp_path): + db = tmp_path / "sessions.db" + _make_db(db) + records = GooseReader("/proj", db_path=db).read() + assert [r["content"][0]["text"] for r in records] == ["hi", "hello"] # s_new only + + +def test_reader_missing_db_is_empty(tmp_path): + records = GooseReader("/proj", db_path=tmp_path / "nope.db").read() + assert records == [] + + +@pytest.fixture +def mlflow_sqlite(tmp_path, monkeypatch): + import mlflow + + monkeypatch.chdir(tmp_path) + uri = f"sqlite:///{tmp_path / 'mlflow.db'}" + mlflow.set_tracking_uri(uri) + mlflow.set_experiment("gooseproj") + return uri + + +def test_end_to_end_through_the_sink(mlflow_sqlite): + import mlflow + + ids = MLflowSink(mlflow_sqlite, "gooseproj").write(_translate()) + assert len(ids) == 2 + exp = mlflow.get_experiment_by_name("gooseproj") + traces = mlflow.search_traces( + experiment_ids=[exp.experiment_id], return_type="list" + ) + assert len(traces) == 2 diff --git a/tests/test_traces_opencode.py b/tests/test_traces_opencode.py new file mode 100644 index 0000000..358b04a --- /dev/null +++ b/tests/test_traces_opencode.py @@ -0,0 +1,170 @@ +"""Fixture tests for the opencode SQLite translator + reader.""" + +import json +import sqlite3 + +import pytest + +from dsagt.observability import MLflowSink +from dsagt.traces import OpenCodeReader, OpenCodeTranslator + + +def _part(role, ts, data, model=None): + return {"role": role, "ts": ts, "model": model, "data": data} + + +def _session(): + return [ + _part("user", 1000.0, {"type": "text", "text": "do the thing"}), # turn 1 + _part("assistant", 1001.0, {"type": "step-start"}), # skip + _part("assistant", 1002.0, {"type": "text", "text": "Working."}, "m"), # LLM + _part( + "assistant", + 1003.0, + { + "type": "tool", + "tool": "shell", + "callID": "c1", + "state": {"input": {"cmd": "ls"}, "output": "a.txt"}, + }, + ), # TOOL (call + result in one part) + _part("assistant", 1004.0, {"type": "step-finish", "tokens": {}}), # skip + _part("assistant", 1005.0, {"type": "text", "text": "Done."}, "m"), # LLM + _part("user", 1006.0, {"type": "text", "text": "next"}), # turn 2 + _part("assistant", 1007.0, {"type": "text", "text": "OK."}, "m"), # LLM + ] + + +def _translate(records=None): + return OpenCodeTranslator().translate( + records if records is not None else _session(), + trace_id="oc", + session_id="proj:s", + project="ocproj", + ) + + +def test_two_turns_with_step_parts_skipped(): + trace = _translate() + roots = [s for s in trace.spans if s["parent_id"] is None] + assert len(roots) == 2 + assert roots[0]["attributes"]["prompt"] == "do the thing" + assert roots[1]["attributes"]["prompt"] == "next" + t1 = [ + (s["kind"], s["name"]) + for s in trace.spans + if s["parent_id"] == roots[0]["span_id"] + ] + assert t1 == [ + ("LLM", "llm"), + ("TOOL", "tool_shell"), + ("LLM", "llm"), + ] + assert roots[0]["attributes"]["response"] == "Done." + + +def test_tool_call_and_result_from_one_part(): + tool = next(s for s in _translate().spans if s["kind"] == "TOOL") + assert tool["attributes"]["input"] == {"cmd": "ls"} + assert tool["attributes"]["result"] == "a.txt" + + +def test_model_carried_onto_llm_spans(): + llm = next(s for s in _translate().spans if s["kind"] == "LLM") + assert llm["model"] == "m" + + +def test_empty_is_none(): + assert _translate([]) is None + assert ( + _translate([_part("assistant", 1.0, {"type": "text", "text": "no prompt"})]) + is None + ) + + +# --- reader over a tiny opencode.db --- + + +def _make_db(path): + con = sqlite3.connect(path) + con.execute("CREATE TABLE session(id TEXT, directory TEXT, time_updated INTEGER)") + con.execute("CREATE TABLE message(id TEXT, session_id TEXT, data TEXT)") + con.execute( + "CREATE TABLE part(id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT, " + "time_created INTEGER, data TEXT)" + ) + con.execute("INSERT INTO session VALUES('s_new','/proj',200)") + con.execute("INSERT INTO session VALUES('s_old','/proj',100)") + con.execute("INSERT INTO session VALUES('s_other','/elsewhere',300)") + con.executemany( + "INSERT INTO message VALUES(?,?,?)", + [ + ("m1", "s_new", json.dumps({"role": "user", "model": {"modelID": "x"}})), + ( + "m2", + "s_new", + json.dumps({"role": "assistant", "model": {"modelID": "x"}}), + ), + ("mo", "s_old", json.dumps({"role": "user"})), + ], + ) + con.executemany( + "INSERT INTO part VALUES(?,?,?,?,?)", + [ + ( + "p1", + "m1", + "s_new", + 1777819990000, + json.dumps({"type": "text", "text": "hi"}), + ), + ( + "p2", + "m2", + "s_new", + 1777819991000, + json.dumps({"type": "text", "text": "hello"}), + ), + ("po", "mo", "s_old", 1, json.dumps({"type": "text", "text": "old"})), + ], + ) + con.commit() + con.close() + + +def test_reader_picks_newest_session_and_converts_ms(tmp_path): + db = tmp_path / "opencode.db" + _make_db(db) + parts = OpenCodeReader("/proj", db_path=db).read() + assert [p["role"] for p in parts] == ["user", "assistant"] # s_new only + assert [p["data"]["text"] for p in parts] == ["hi", "hello"] + assert parts[0]["ts"] == 1777819990.0 # ms → s + assert parts[0]["model"] == "x" + + +def test_reader_missing_db_is_empty(tmp_path): + parts = OpenCodeReader("/proj", db_path=tmp_path / "nope.db").read() + assert parts == [] + + +@pytest.fixture +def mlflow_sqlite(tmp_path, monkeypatch): + import mlflow + + monkeypatch.chdir(tmp_path) + uri = f"sqlite:///{tmp_path / 'mlflow.db'}" + mlflow.set_tracking_uri(uri) + mlflow.set_experiment("ocproj") + return uri + + +def test_end_to_end_through_the_sink(mlflow_sqlite): + import mlflow + + ids = MLflowSink(mlflow_sqlite, "ocproj").write(_translate()) + assert len(ids) == 2 + exp = mlflow.get_experiment_by_name("ocproj") + traces = mlflow.search_traces( + experiment_ids=[exp.experiment_id], return_type="list" + ) + assert len(traces) == 2 From 34caf5d7aa5f1040083d482341ae5d64b9ca9915 Mon Sep 17 00:00:00 2001 From: aarontuor Date: Thu, 2 Jul 2026 10:39:10 -0700 Subject: [PATCH 18/44] =?UTF-8?q?refactor:=20rename=20data-processing=20"t?= =?UTF-8?q?ools"=20=E2=86=92=20"codes";=20reserve=20"tool"=20for=20MCP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reserve "tool"/"tools" for the MCP + agent sense; registered CLI executables the agent runs via dsagt-run are now "codes" throughout. - Registry: /tools/ → codes/ (+ codes/scripts/), ToolRegistry → CodeRegistry, save_tool_spec → save_code_spec, search_registry takes code_name, dsagt-run --tool → --code, list_tools/get_tool → list_codes/get_code. - Collections/spans: tools → codes, tool_use → code_use, tool.execute → code.execute; provenance/registry metadata tool_name → code_name. Kept tool_name for session_memory (agent tool calls); kb_search now accepts both code_name and tool_name. - src/dsagt/tools/ → src/dsagt/codes/, run_tool.py → run_code.py (+ package-data, dsagt-run entry point, bundled scan_directory). - Agent instructions, CLAUDE.md, README, and all docs updated to the convention. Kept the Anthropic tool_use block type and the MCP SDK @server.list_tools() decorator (both the reserved sense). Docs also: fixed staleness (dropped Roo, the removed mlflow-autolog hook, the removed LLM judge), trimmed implementation-detail TMI and negative framing, simplified jargon (provisioning → setup, dropped "layer"). Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 3 + CHANGELOG.md | 33 +- CLAUDE.md | 112 ++- NOTICE | 12 - README.md | 99 ++- docs/architecture.md | 29 +- docs/cli.md | 26 +- docs/developer.md | 21 +- docs/index.md | 11 +- docs/knowledge-base.md | 27 +- docs/mcp-servers.md | 12 +- docs/observability.md | 18 +- docs/quickstart.md | 42 +- docs/tools-skills.md | 26 +- pyproject.toml | 36 +- src/dsagt/__init__.py | 4 +- src/dsagt/agents/__init__.py | 2 +- src/dsagt/agents/base.py | 8 +- src/dsagt/agents/claude.py | 2 +- src/dsagt/agents/codex.py | 8 +- src/dsagt/{tools => codes}/scan_directory.md | 2 +- src/dsagt/{tools => codes}/scan_directory.py | 0 src/dsagt/commands/cli.py | 80 +-- src/dsagt/commands/info.py | 146 +--- .../commands/{run_tool.py => run_code.py} | 10 +- src/dsagt/commands/setup_core_kb.py | 38 +- src/dsagt/dsagt_instructions.md | 68 +- src/dsagt/judge.py | 310 --------- src/dsagt/knowledge.py | 60 +- src/dsagt/mcp/__init__.py | 2 +- src/dsagt/mcp/knowledge_tools.py | 52 +- src/dsagt/mcp/memory_tools.py | 98 +-- src/dsagt/mcp/registry_tools.py | 88 +-- src/dsagt/mcp/server.py | 133 ++-- src/dsagt/mcp/skill_tools.py | 8 +- src/dsagt/memory.py | 619 ++++------------- src/dsagt/observability.py | 655 +++++++----------- src/dsagt/provenance.py | 94 +-- src/dsagt/registry.py | 160 ++--- src/dsagt/session.py | 153 ++-- src/dsagt/skills.py | 2 +- src/dsagt/traces.py | 166 ++++- tests/conftest.py | 54 +- .../smoke_test/manual_runs/cli_walkthrough.md | 6 +- .../manual_runs/vscode_walkthrough.md | 2 +- tests/smoke_test/run.sh | 4 +- tests/test_chroma_metadata.py | 21 +- tests/test_config.py | 70 +- tests/test_dependency_integration.py | 17 +- tests/test_dsagt_server.py | 44 +- tests/test_episodic_integration.py | 83 ++- tests/test_extraction.py | 174 ----- tests/test_info.py | 70 +- tests/test_judge.py | 124 ---- tests/test_kb_search_filters.py | 75 +- tests/test_knowledge_integration.py | 54 +- tests/test_knowledge_server.py | 39 +- tests/test_memory.py | 12 +- tests/test_memory_extractor.py | 107 +-- tests/test_observability.py | 395 +++++------ tests/test_outlier.py | 251 ------- tests/test_pipeline.py | 20 +- tests/test_registry.py | 82 +-- tests/test_registry_server.py | 130 ++-- tests/test_run.py | 44 +- tests/test_server_startup.py | 11 +- tests/test_setup_core_kb.py | 22 +- tests/test_tool_executions.py | 144 +--- tests/test_trace_scan.py | 185 ++++- tests/test_traces.py | 25 +- tests/test_traces_codex.py | 4 +- 71 files changed, 2164 insertions(+), 3580 deletions(-) delete mode 100644 NOTICE rename src/dsagt/{tools => codes}/scan_directory.md (94%) rename src/dsagt/{tools => codes}/scan_directory.py (100%) rename src/dsagt/commands/{run_tool.py => run_code.py} (87%) delete mode 100644 src/dsagt/judge.py delete mode 100644 tests/test_extraction.py delete mode 100644 tests/test_judge.py delete mode 100644 tests/test_outlier.py diff --git a/.gitignore b/.gitignore index c37bb57..671a964 100644 --- a/.gitignore +++ b/.gitignore @@ -151,3 +151,6 @@ cline_mcp.json *.bak *.swp .*.swp + +# Local scratch for removed/parked code (not tracked) +scratch/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 169cbe4..ea7b32d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,21 +18,15 @@ intercepting its LLM traffic — and ships episodic memory end to end. supported agent (claude, codex, goose, opencode, cline), with no proxy, no OTel routing, and no credentials. DSAgt's own `kb.*` / `tool.execute` / registry spans flow to the same store. -- **Episodic memory (opt-in).** `dsagt init --episodic` (with optional - `--domain-tags "a,b"`) turns on automatic session memory: the heartbeat - distills each completed turn into a few tagged, ≤1-sentence facts in the - per-project `session_memory` collection, retrievable via - `kb_search` / `kb_get_memories`. Two tiers — **Tier-1** uses a small **local** - LLM judge (`Qwen2.5-1.5B`, grammar-constrained JSON; no API key, ~1 GB GGUF - downloaded on first use), **Tier-0** is a mechanical chunk+tag+embed fallback - used automatically if the judge fails, so a turn is never lost. +- **Episodic memory (opt-in).** `dsagt init --episodic` turns on automatic + session memory: the heartbeat mechanically chunks, keyword-tags (against a + closed "AI-data-ready" taxonomy), and embeds each completed turn into the + per-project `session_memory` collection — no LLM, no credentials — + retrievable via `kb_search` / `kb_get_memories`. - **Recency-weighted episodic retrieval** (`episodic.recency_half_life_days`, - default 14): a newer fact edges out a stale one as a bounded boost, so a - corrected fact wins without contradiction detection while durable old facts - keep their relevance. -- `llama-cpp-python` as a core dependency for the local judge, pinned and - installed from a prebuilt CPU wheel index so a plain `uv sync` never compiles - llama.cpp (GPU on CUDA HPC is an opt-in reinstall). + default 14): a newer turn edges out a stale one as a bounded boost, so a more + recent turn wins without contradiction detection while durable old turns keep + their relevance. ### Changed - **Tool-use indexing is now incremental.** `dsagt-run` execution records are @@ -50,6 +44,17 @@ intercepting its LLM traffic — and ships episodic memory end to end. ### Removed - Dead `provenance.index_execution_record` (the orphaned single-record path, superseded by the heartbeat's batched, idempotent indexer). +- **Episodic LLM-judge distillation layer** (`judge.py`: `Judge` / `LocalJudge` + / `APIJudge` + `llama-cpp-python`) and the **outlier-suggestion** feature + (`CategoryCentroids` / `SuggestionQueue` + the `kb_get_suggestions` / + `kb_dismiss_suggestion` MCP tools, 23 → 21 tools). Episodic memory keeps only + the mechanical capture path so a Tier-0 baseline can be measured before the + judge's runtime/dependency cost is justified; the design notes and parked code + live in `design-notes/judge.md` and `scratch/`. +- `dsagt init` no longer prompts for an LLM judge or solicits a per-project + memory taxonomy; the `--domain-tags` flag and the `episodic.judge` / + `episodic.domain_tags` / `episodic.outlier_sensitivity` config keys are gone. + The stock taxonomy is the fixed tag set. ## [0.2.0] - 2026-06-24 diff --git a/CLAUDE.md b/CLAUDE.md index ecb1742..f998d70 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,21 +1,21 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +Guidance for Claude Code (claude.ai/code) when working in this repository. ## Project Overview -DSAGT (DataSmith Agent) is an AI-assisted data pipeline builder that exposes an MCP (Model Context Protocol) server to agent platforms (Claude Code, Goose, Roo, Cline, Codex, opencode). It helps domain scientists create reproducible, auditable data curation pipelines through iterative, knowledge-driven tool generation. +DSAGT (DataSmith Agent) is an AI-assisted data pipeline builder. It exposes an MCP (Model Context Protocol) server to agent platforms (Claude Code, Goose, Cline, Codex, opencode) that helps domain scientists build reproducible, auditable data-curation pipelines through iterative, knowledge-driven code generation. ## Run model (BYOA, serverless) -DSAGT is **BYOA (Bring Your Own Agent)**: the agent talks to its own LLM provider directly — DSAGT never interposes on its network traffic (the LiteLLM proxy was removed). `dsagt init --agent ` writes the per-agent instructions file + MCP config and nothing to paste (no launch shim, no OTel/env instructions). Two supported, behaviorally-identical start flows: +DSAGT is **bring-your-own-agent**: the agent talks to its own LLM provider directly — DSAGT never proxies that traffic. `dsagt init --agent ` writes a per-agent instructions file plus MCP config; nothing to paste, no launch shim, no OTel/env wiring. Two behaviorally-identical start flows: 1. **Bare launch** — start the agent from the project dir (`cd && claude` / `goose` / …). -2. **`dsagt start `** — spawns the agent for you in the foreground and, on exit, runs post-session `run_extraction`. Its real job is owning a reliable session-end trigger. +2. **`dsagt start `** — spawns the agent in the foreground and, on exit, runs post-session `run_extraction`. Its real job is owning a reliable session-end trigger. -The MCP server is self-sufficient: it derives the project from its cwd (`dsagt_config.yaml`), with the MCP-config env block as robustness, and behaves identically regardless of how it was launched. +The MCP server is self-sufficient: it derives the project from its cwd (`.dsagt/config.yaml`), with the MCP-config env block as robustness, and behaves identically regardless of how it was launched. -**Serverless store.** Self-logging (MCP-server + `dsagt-run` spans, and Claude's `mlflow autolog` Stop hook) goes to a `sqlite:////mlflow.db` MLflow store — **no server to run** (SQLite is MLflow's supported serverless backend; the filesystem `./mlruns` store is deprecated). The resolver (`observability.resolve_tracking_uri`) order is `MLFLOW_TRACKING_URI` env → config → that sqlite default, and never raises. The DB auto-creates/migrates on first span; DSAGT emits only traces, so no artifact dir materializes. View traces with `mlflow ui --backend-store-uri sqlite:////mlflow.db`. DSAGT writes **no** telemetry env on the agent (no forced OTel) — agent LLM-call history is recovered post-hoc from the on-disk transcript (Phase 2's pipeline), not by intercepting traffic. +**Serverless store.** All self-logging goes to a `sqlite:////mlflow.db` MLflow store — no server to run (SQLite is MLflow's supported serverless backend). `observability.resolve_tracking_uri` always computes that path from the project dir (no env/config override) and never raises. The DB auto-creates on first span; DSAGT emits only traces, so no artifact dir materializes. View with `mlflow ui --backend-store-uri sqlite:////mlflow.db`. Agent LLM-call history is recovered post-hoc from the on-disk transcript (the trace pipeline), not by intercepting traffic. ## Commands @@ -26,44 +26,43 @@ uv run black . # format uv run ruff check . # lint ``` -**`python -m pytest` not bare `pytest`.** The bare-binary form picks up the wrong pytest on this machine and crashes with `ModuleNotFoundError: dsagt`. +**`python -m pytest`, not bare `pytest`.** The bare binary picks up the wrong pytest on this machine and crashes with `ModuleNotFoundError: dsagt`. -**Don't run the full suite by default.** ~50s for ~590 tests is too slow for an iteration loop. Run only the test file relevant to the change. `tests/test_config.py` covers session, init, agents, BYOA hints, the serverless store resolution, and the no-launch-shim/no-OTel invariants. Skip `test_integration.py`, `test_*_integration.py`, `test_server_startup.py`, `test_dependency_integration.py` unless explicitly relevant — they hit network or spawn subprocesses. +**Don't run the full suite by default** — ~50s for ~590 tests is too slow for an iteration loop. Run only the test file relevant to the change. `tests/test_config.py` covers session, init, agents, BYOA hints, serverless-store resolution, and the no-launch-shim/no-OTel invariants. Skip `test_integration.py`, `test_*_integration.py`, `test_server_startup.py`, `test_dependency_integration.py` unless relevant — they hit the network or spawn subprocesses. ## Code Organization -The codebase separates **commands** (entry points with argparse, launched as CLI tools or subprocesses) from **modules** (importable logic). Commands live in `src/dsagt/commands/`, modules live in `src/dsagt/`. +The codebase separates **commands** (entry points with argparse, launched as CLI tools or subprocesses) from **modules** (importable logic). Commands live in `src/dsagt/commands/`, modules in `src/dsagt/`. **Commands** (`src/dsagt/commands/`): -- `cli.py` — `dsagt init / start / info / list / mv / rm / smoke-test` (user-facing CLI). `dsagt start` launches the agent in the foreground and runs post-session extraction on exit. -- `run_tool.py` — `dsagt-run` (tool execution wrapper). +- `cli.py` — `dsagt init / start / info / list / mv / rm / smoke-test`. `dsagt start` launches the agent in the foreground and runs post-session extraction on exit. +- `run_code.py` — `dsagt-run` (code execution wrapper). - `setup_core_kb.py` — KB-asset build engine (`resolve_assets` / `ensure_assets`), called by `dsagt init` to provision the shared KB; not a CLI command of its own. -- `info.py` — `dsagt info` (project / config introspection + trace triage). +- `info.py` — `dsagt info` (project/config introspection + trace triage). (The MCP server — `dsagt-server` — lives in the `src/dsagt/mcp/` package, see below.) **Modules** (`src/dsagt/`): -- `session.py` — Project init, agent config generation, env-var resolution, config load/validate, session-id minting (`new_session_id`), end-of-session extraction orchestration (`run_extraction`). No service supervision — the store is serverless. -- `agents/` — Per-agent-platform setup (`base.py` ABC + `claude.py` / `goose.py` / `cline.py` / `roo.py` / `codex.py` / `opencode.py`). Each subclass owns its `write_static`, `write_dynamic`, `runtime_env` (per-project state dirs only), `byoa_env_hints`, `vscode_hint`. Shared helpers (`_mcp_env_block`, `_build_mcp_servers_dict`) in `base.py`. DSAGT sets no telemetry/OTel env and writes no launch shim. -- `knowledge.py` — ChromaDB document retrieval, embedding backends, per-collection routing (FAISS removed). -- `registry.py` — `ToolRegistry` (CLI tools) + `SkillRegistry` (agent instruction skills), KB indexing. -- `provenance.py` — Tool execution records (`run_and_record`, `ToolRecordStore`), execution-record indexing into ChromaDB, pipeline reconstruction (`reconstruct_pipeline`, dependency graph). -- `observability.py` — MLflow tracing setup over the serverless file store; `resolve_tracking_uri` (never-raise), `init_tracing`, span helpers. -- `memory.py` — Explicit memory (YAML, `ExplicitMemory`) + the episodic `MemoryExtractor` (a `traces.TraceCollector` consumer: Tier-0 mechanical chunk+tag+embed always, Tier-1 `judge.Judge` distillation when enabled, falling back to Tier-0 on judge failure) + outlier-suggestion queue (`SuggestionQueue`). Facts carry `ts_epoch` for recency-weighted retrieval. `extract_session` remains a no-op stub kept only for the deferred cross-session N+1 catch-up call site — the heartbeat consumer is the live episodic path. The LLM judge lives in `judge.py`. -- `skills.py` — External skill catalog data plane (`SkillsCatalog`: clone/sync/index/install), the `SkillRouter` render facade, and the Genesis-derived keyword scorer (`rank_skills`). -- `traces.py` — the whole trace pipeline in one module: the pure-data `Trace` (a list of span dicts + compose/query/`to_exchanges` methods; the span/message/block shapes have one home, `Trace`'s `add_*` methods), the `Reader`/`Translator` ABCs with a per-agent subclass each (Claude bespoke; codex/goose/opencode/cline share the `Translator` turn-template; claude+codex share `JsonlReader`), and `TraceCollector` — the MCP-server heartbeat that reads→translates→hands the `Trace` to its consumers (the MLflow logger, the memory distiller), each with its own ack set for idempotency. Imports nothing heavy (mlflow is lazy, consumer-side). -- `judge.py` — the episodic Tier-1 `Judge` (`Judge.create` → `LocalJudge` GGUF default / `APIJudge` stub) + the lean per-turn distill prompt/parser. Local-by-default (no API key); `distill` is grammar-constrained on `LocalJudge`, a no-op on `APIJudge`. - -**MCP server** (`src/dsagt/mcp/`) — the single merged `dsagt-server`. `server.py` owns `main()`, the shared-KB startup (`_build_kb_from_config`), and the dispatch shell (`build_dispatch_server`); the tool surface is split by concern across `registry_tools.py` (tool registry + execution + provenance, 8 tools), `knowledge_tools.py` (KB retrieval, 6), `memory_tools.py` (explicit memory + suggestions, 4), and `skill_tools.py` (skill search/install/sources, 5). Each `*_tools.py` exposes a `_*_tools_and_handlers()` factory (composed by `create_dsagt_server`) plus a `create_*_server` test wrapper. - -Entry points (`pyproject.toml` `[project.scripts]`): `dsagt` → `dsagt.commands.cli:main`, `dsagt-run` → `dsagt.commands.run_tool:main`, `dsagt-server` → `dsagt.mcp.server:main`. - -**Bundled assets** (shipped as `package-data` in `pyproject.toml`): -- `src/dsagt/tools/` — built-in tool specs (markdown + YAML frontmatter) copied into new projects. +- `session.py` — Project init, agent config generation, env-var resolution, config load/validate, session-id minting (`new_session_id`), end-of-session extraction (`run_extraction`), and startup **catch-up** (`catch_up_extraction`): code-use indexing + a chat-trace re-collect (`_catch_up_traces`) of the *previous* session (pinned to the `trace_source` token in `state.yaml`) so turns lost to an ungraceful shutdown still reach MLflow + episodic memory — uniform across all agents. +- `agents/` — Per-agent-platform setup (`base.py` ABC + `claude.py` / `goose.py` / `cline.py` / `codex.py` / `opencode.py`). Each subclass owns its `write_static`, `write_dynamic`, `runtime_env`, `byoa_env_hints`, `vscode_hint`. Shared helpers (`_mcp_env_block`, `_build_mcp_servers_dict`) in `base.py`. DSAGT sets no telemetry/OTel env and writes no launch shim. +- `knowledge.py` — ChromaDB document retrieval, embedding backends, per-collection routing (the reference example of the house style). +- `registry.py` — `CodeRegistry` (CLI codes) + `SkillRegistry` (agent instruction skills), KB indexing. +- `provenance.py` — Code execution records (`run_and_record`), execution-record indexing into ChromaDB (`CodeUseIndexer` → `code_use` collection), pipeline reconstruction (`reconstruct_pipeline`, dependency graph). +- `observability.py` — first-party span emission over the serverless sqlite store via MLflow's native `mlflow.start_span` (no OTel `TracerProvider`). `resolve_tracking_uri` (never-raise), `init_tracing`, `@traced`/`obs`/`child_span` + typed span helpers. Each internal trace's root is tagged `dsagt.source` with the MCP tool *category* (`memory`/`skill`/`knowledge`/`registry`, or `execution` for dsagt-run) so the MLflow UI can filter the debug view apart from agent traces. `MLflowSink` (a `traces.Trace` consumer) replays finished transcripts via `start_span_no_context`. +- `memory.py` — Explicit memory (YAML, `ExplicitMemory`) + the episodic `MemoryExtractor` (a `traces.TraceCollector` consumer that mechanically chunks+tags+embeds every turn, no LLM). Turns carry `ts_epoch` for recency-weighted retrieval. `extract_session` is a no-op stub kept only for the deferred cross-session N+1 catch-up call site. +- `skills.py` — External skill-catalog data plane (`SkillsCatalog`: clone/sync/index/install), the `SkillRouter` render facade, and the Genesis-derived keyword scorer (`rank_skills`). +- `traces.py` — the whole trace pipeline in one module: the pure-data `Trace` (span dicts + compose/query/`to_exchanges`), the `Reader`/`Translator` ABCs with a per-agent subclass each (Claude bespoke; codex/goose/opencode/cline share the `Translator` turn-template; claude+codex share `JsonlReader`), and `TraceCollector` — the MCP-server heartbeat that reads→translates→hands the `Trace` to its consumers (MLflow logger, memory indexer), each with its own ack set for idempotency. Imports nothing heavy (mlflow is lazy, consumer-side). + +**MCP server** (`src/dsagt/mcp/`) — the single merged `dsagt-server`. `server.py` owns `main()`, the shared-KB startup (`_build_kb_from_config`), and the dispatch shell (`build_dispatch_server`). The 20-tool surface is split by concern: `registry_tools.py` (code registry + execution + provenance, 8), `knowledge_tools.py` (KB retrieval, 5), `memory_tools.py` (explicit memory, 2), `skill_tools.py` (skill search/install/sources, 5). Each `*_tools.py` exposes a `_*_tools_and_handlers()` factory (composed by `create_dsagt_server`) plus a `create_*_server` test wrapper. + +Entry points (`pyproject.toml` `[project.scripts]`): `dsagt` → `dsagt.commands.cli:main`, `dsagt-run` → `dsagt.commands.run_code:main`, `dsagt-server` → `dsagt.mcp.server:main`. + +**Bundled assets** (shipped as `package-data`): +- `src/dsagt/codes/` — built-in code specs (markdown + YAML frontmatter) copied into new projects. - `src/dsagt/skills/` — built-in skills (e.g., `skill-creator`) the agent discovers via `search_skills`. -- `src/dsagt/dsagt_instructions.md` — agent-platform-agnostic system instructions injected into per-agent files at init. +- `src/dsagt/dsagt_instructions.md` — agent-agnostic system instructions injected into per-agent files at init. -**`use_cases/`** holds end-to-end domain walkthroughs (`microbial_isolates/`, `cryoem/`, `isaac_vasp/`). They are reference material for users, not part of the test suite. +**`use_cases/`** holds end-to-end domain walkthroughs. They are reference material for users, not part of the test suite. ## Code style & conventions @@ -71,11 +70,11 @@ Distilled from working on this codebase; `knowledge.py` is the reference example **No defensive swallowing.** Don't add guards that silently absorb empty/invalid input (`if not texts: return []`, empty-array short-circuits, disk-state "reconciliation" of can't-happen states). They convert a caller's bug into a silent success you'll never see. Empty/invalid input is out-of-contract — let it surface. Translating a *real, reachable* exception into an actionable message (e.g. a dim-mismatch hint) is different and welcome; swallowing is not. -**YAGNI / no speculative generality.** Don't add a flag or option for a path never exercised in practice. Model real variation *structurally* (a subclass / distinct type), not with a runtime toggle nothing flips — e.g. the local store is unconditionally hybrid; "dense-only" is a future store *type*, not a `hybrid=False` flag. Don't extract a base class until a second concrete impl forces the seam (you can't validate the abstraction with one impl). This is dev-stage: gut cleanly, no back-compat shims, aim net-minus LOC. +**YAGNI / no speculative generality.** Don't add a flag or option for a path never exercised in practice. Model real variation *structurally* (a subclass / distinct type), not with a runtime toggle nothing flips — e.g. the local store is unconditionally hybrid; "dense-only" is a future store *type*, not a `hybrid=False` flag. Don't extract a base class until a second concrete impl forces the seam. This is dev-stage: gut cleanly, no back-compat shims, aim net-minus LOC. **Explicit named arguments, never `**kwargs` config-splat.** A dict of kwargs threaded through layers and `**`-splatted into a constructor hides what's actually passed. Use explicit named params; unpack any config dict at the boundary (e.g. `Embedder.create(backend, *, model=, base_url=, ...)`, callers pass `model=cfg.get("model")`). -**Put behavior where it belongs.** Factories live on the class they build (`Embedder.create` classmethod, not a free `_make_embedder`). Trivial field getters are unpythonic — expose the attribute; but a *method* is right when access does real work (lazy I/O + memoization like `_get_bm25` — the name signals cost a bare subscript would hide). Pure, stateless algorithms shared by multiple classes stay module-level functions (RRF: `_rrf_merge`/`_rrf_across`), not staticmethods nailed to one arbitrary owner. +**Put behavior where it belongs.** Factories live on the class they build (`Embedder.create` classmethod, not a free `_make_embedder`). Trivial field getters are unpythonic — expose the attribute; but a *method* is right when access does real work (lazy I/O + memoization like `_get_bm25`). Pure, stateless algorithms shared by multiple classes stay module-level functions (RRF: `_rrf_merge`/`_rrf_across`), not staticmethods nailed to one arbitrary owner. **Comments state the real reason, at the point they explain it.** A lazy import is justified *at the import site* with its actual cause, not as an "this is absent" note in the import block citing a stale rationale. If the reason changes, fix the comment. @@ -83,62 +82,61 @@ Distilled from working on this codebase; `knowledge.py` is the reference example **Naming.** Prefer concise domain names (`APIEmbedder`/`LocalEmbedder`, not `…EmbeddingClient`). -**Module docstrings (major modules).** Open with a title line + 3–5 sentences: what the module does, the capabilities it backs, and the design motivations. Follow it with an **ASCII-art UML class map** — one consistent notation throughout (`knowledge.py` uses `◇` holds · `◆` owns · `▷` inherits, every edge drawn the same way). Treat the class-map diagram as a deliverable of any **major module refactor** — add or refresh it whenever the class structure changes substantially. +**Module docstrings (major modules).** Open with a title line + 3–5 sentences: what the module does, the capabilities it backs, the design motivations. Follow with an **ASCII-art UML class map** — one consistent notation throughout (`knowledge.py` uses `◇` holds · `◆` owns · `▷` inherits). Treat the class-map diagram as a deliverable of any **major module refactor** — refresh it whenever the class structure changes substantially. ## BYOA artifacts -`dsagt init --agent X --location ` writes (in the project dir): -- `dsagt_config.yaml` — internal config (project name, agent, embedding/knowledge/extraction/skills settings). No mlflow port (the store is the serverless `sqlite:////mlflow.db`), no user-facing fields, no credentials. +`dsagt init --agent X --location ` writes, in the project dir: +- `.dsagt/config.yaml` — internal config (project name, agent, embedding/knowledge/extraction/skills settings). No mlflow port (the store is the serverless `sqlite:////mlflow.db`), no user-facing fields, no credentials. `.dsagt/state.yaml` (session log + memory cursor) and `.dsagt/explicit_memories.yaml` live alongside it, owned by the MCP server. - Per-agent instructions file (e.g., `CLAUDE.md`, `.goosehints`, `AGENTS.md`). -- Per-agent MCP config artifact (`.mcp.json` for claude, `goose.yaml` for goose, `cline_mcp_settings.json` via `cline mcp add`, `.roo/mcp.json`, `.codex-data/config.toml`). The env block carries benign routing only (DSAGT_PROJECT, DSAGT_PROJECT_DIR, DSAGT_SESSION_ID, MLFLOW_TRACKING_URI, EMBEDDING_*) so MCP-server children of agents that don't inherit shell env (codex/cline/roo) still log to the right store. No credentials, no OTel routing, no privacy overrides. -- For claude, `.claude/settings.json` wiring the `mlflow autolog claude` Stop hook (transcript → file store at session end). +- Per-agent MCP config artifact (`.mcp.json` for claude, `goose.yaml` for goose, `cline_mcp_settings.json` via `cline mcp add`, `.codex-data/config.toml`). The env block carries benign routing only (`DSAGT_PROJECT`, `DSAGT_PROJECT_DIR`, `DSAGT_SESSION_ID`, `MLFLOW_TRACKING_URI`, `EMBEDDING_*`) so MCP-server children of agents that don't inherit shell env (codex/cline) still log to the right store. No credentials, no OTel routing. -No launch shim is written and `dsagt init` prints no env/OTel instructions — the user starts the agent directly or via `dsagt start`. +No launch shim is written and `dsagt init` prints no env/OTel instructions — the user starts the agent directly or via `dsagt start`. DSAGT wires no MLflow autolog hook: Claude's traces (like every agent's) come from the heartbeat pipeline, not native autolog. ## Architecture ### MCP Server -A single merged `dsagt-server` (`src/dsagt/mcp/`) exposes 23 tools across four concern modules under one `Server` + one shared `KnowledgeBase`: +A single merged `dsagt-server` (`src/dsagt/mcp/`) exposes 20 tools across four concern modules under one `Server` + one shared `KnowledgeBase`: -1. **Registry tools** (`mcp/registry_tools.py` + `registry.py` / `provenance.py`) — tool analysis, registration, dependency installation, command/file/http execution, and pipeline reconstruction. Tools are saved as markdown specs with YAML frontmatter. +1. **Registry tools** (`mcp/registry_tools.py` + `registry.py` / `provenance.py`) — tool analysis, registration, dependency installation, command/file/http execution, pipeline reconstruction. Tools are saved as markdown specs with YAML frontmatter. 2. **Knowledge tools** (`mcp/knowledge_tools.py` + `knowledge.py`) — semantic search over document collections (ChromaDB, optional cross-encoder reranking); long ops run as background jobs. -3. **Memory tools** (`mcp/memory_tools.py` + `memory.py`) — explicit memory + outlier suggestions (`kb_remember` / `kb_get_memories` / …). -4. **Skill tools** (`mcp/skill_tools.py` + `skills.py`) — skill search / install + external catalog sources. +3. **Memory tools** (`mcp/memory_tools.py` + `memory.py`) — explicit memory (`kb_remember` / `kb_get_memories`). +4. **Skill tools** (`mcp/skill_tools.py` + `skills.py`) — skill search/install + external catalog sources. ### Observability -- **Serverless MLflow store** — Spans land in `sqlite:////mlflow.db` (no server). View with `mlflow ui --backend-store-uri sqlite:////mlflow.db`. The tracking URI resolves via `observability.resolve_tracking_uri` (env → config → sqlite default; never raises). -- **dsagt-run** (`commands/run_tool.py` + `provenance.py`) — Wraps tool commands; captures execution layer (command, stdout/stderr, timing, file lists) into `trace_archive/`, and emits `tool.execute` spans. -- **MCP-server spans** — `dsagt-server` calls `init_tracing()` at startup; its tool spans (kb.*, registry.*) flow to the file store. Session correlation via `DSAGT_SESSION_ID` (minted by `dsagt start`). -- **Agent traces** — recovered post-hoc from the on-disk transcript, not native OTel. Today only claude's `mlflow autolog` Stop hook populates agent traces; the general transcript pipeline is Phase 2. +- **Serverless MLflow store** — spans land in `sqlite:////mlflow.db` (no server). The tracking URI resolves via `observability.resolve_tracking_uri` (never raises). +- **dsagt-run** (`commands/run_code.py` + `provenance.py`) — wraps code commands; captures the execution layer (command, stdout/stderr, timing, file lists) into `trace_archive/` and emits `code.execute` spans. +- **MCP-server + tool spans (debug view)** — `dsagt-server` calls `init_tracing()` at startup; the dispatch shell opens one categorization-root span per tool call (subsystem `kb.*`/`registry.*` spans nest under it). Each root is tagged `dsagt.source` with its concern category so it filters apart as a debugging view. Session grouping via `DSAGT_SESSION_ID`. +- **Agent traces** — recovered post-hoc from the on-disk transcript by the MCP-server heartbeat's trace pipeline (`traces.py`), uniform across all five agents. No native OTel, no autolog. ### Memory System -- **Explicit memory** (`memory.py:ExplicitMemory`) — User-confirmed facts in YAML, loaded into agent context at session start via the `kb_remember` / `kb_get_memories` MCP tools (the vector mirror is optional — degrades to pure-YAML if the store is down). -- **Tool-execution indexing** — `provenance.ToolUseIndexer` embeds `trace_archive/` records into the project's `tool_use` collection incrementally on the MCP-server heartbeat (idempotent via a persisted ack set), plus a startup catch-up and an on-demand tick before `reconstruct_pipeline`. No LLM, no credentials. -- **Episodic memory** — live, **opt-in** (`episodic.enabled`, via `dsagt init --episodic`). The `memory.MemoryExtractor` consumer consumes `Trace.to_exchanges()` on the heartbeat: Tier-0 mechanical (chunk+tag+embed, no LLM) always; Tier-1 distillation via `judge.LocalJudge` (Qwen2.5-1.5B GGUF, GBNF-grammar JSON) when a judge backend is configured, degrading to Tier-0 on failure. Retrieval is recency-weighted (`episodic.recency_half_life_days`). `extract_session` stays a no-op stub for the deferred N+1 catch-up only. There is no user-facing `dsagt memory` command. +- **Explicit memory** (`memory.py:ExplicitMemory`) — user-confirmed facts in YAML, loaded into agent context at session start via `kb_remember` / `kb_get_memories` (the vector mirror is optional — degrades to pure-YAML if the store is down). +- **Code-execution indexing** — `provenance.CodeUseIndexer` embeds `trace_archive/` records into the project's `code_use` collection incrementally on the heartbeat (idempotent via a persisted ack set), plus a startup catch-up and an on-demand tick before `reconstruct_pipeline`. No LLM. +- **Chat-trace catch-up** — the heartbeat logs the live transcript to MLflow (+ episodic memory) and a graceful shutdown flushes the deferred final turn; an ungraceful kill is backstopped at the *next* session's startup by `session._catch_up_traces`, which re-collects the previous session pinned to its recorded `trace_source` token. Idempotency rests on the collector's **session-qualified** ack keys (`:`). +- **Episodic memory** — live, **opt-in** (`episodic.enabled`, via `dsagt init --episodic`). The `memory.MemoryExtractor` consumer consumes `Trace.to_exchanges()` on the heartbeat and mechanically chunks+tags+embeds every turn into `session_memory` (no LLM). Retrieval is recency-weighted (`episodic.recency_half_life_days`). ### Key Design Patterns - **Agent-agnostic**: DSAGT is infrastructure, not an agent. Capabilities are MCP services. -- **Session isolation**: Each project gets its own directory with config, tools, skills, kb_index, trace_archive, and the `mlflow.db` sqlite trace store. -- **Tools vs Skills**: Tools are CLI executables in `/tools/` (specs with parameters, wrapped by dsagt-run). Skills are agent instruction workflows in `/skills/` (SKILL.md + reference docs). Both are discoverable via ChromaDB-backed semantic search. +- **Session isolation**: each project gets its own directory with config, tools, skills, kb_index, trace_archive, and the `mlflow.db` sqlite store. +- **Codes vs Skills**: Codes are CLI executables in `/codes/` (specs with parameters, wrapped by dsagt-run). Skills are agent instruction workflows in `/skills/` (SKILL.md + reference docs). Both are discoverable via ChromaDB-backed semantic search. ## DSAGT Pipeline Builder Workflow When acting as a pipeline builder (using the MCP server), follow these constraints: -1. **Never directly access data** — all data operations go through registered tools. -2. **Tool preference hierarchy**: Registered tool → KB package tool → Custom implementation. +1. **Never directly access data** — all data operations go through registered codes. +2. **Code preference hierarchy**: registered code → KB package code → custom implementation. 3. **Generate paired tools** — every data operation gets a check tool (pre/post audit) and an operation tool. 4. **Audit everything** — before/after JSON reports saved to `audit/`. 5. **One step at a time** — iterate with the user, confirming approach before execution. ## Testing Patterns -- Tests use pytest with `subprocess.run` mocking for command execution. -- MCP server tests invoke handlers directly (no stdio transport). -- Async tests for server handlers. +- pytest with `subprocess.run` mocking for command execution. +- MCP server tests invoke handlers directly (no stdio transport); async tests for server handlers. - Temp directories for isolation; the `_use_tmp_registry` fixture in `tests/test_config.py` patches `DEFAULT_PROJECTS_BASE` and the project registry to `tmp_path`. - Integration tests in `test_*_integration.py` require real `EMBEDDING_*` / `LLM_*` credentials. diff --git a/NOTICE b/NOTICE deleted file mode 100644 index a5f3c99..0000000 --- a/NOTICE +++ /dev/null @@ -1,12 +0,0 @@ -DSAGT (DataSmith Agent) -Copyright 2026 the DSAGT authors - -This product includes software developed at Databricks, Inc. - -Portions of src/dsagt/traces.py (the Claude transcript-parsing grammar — last -user-message selection, per-turn input windowing, and next-timestamp span -durations) are ported and adapted from MLflow's mlflow/claude_code/tracing.py. - - MLflow — Copyright (c) Databricks, Inc. - Licensed under the Apache License, Version 2.0. - https://github.com/mlflow/mlflow diff --git a/README.md b/README.md index 2af1c79..5f4fd9e 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ ![DSAgt architecture](latex/architecture.png) -DSAgt connects an MCP-compatible AI coding agent to tool registration, a semantic knowledge base, skills discovery and creation, execution provenance, and observability infrastructure. DSAgt provides data-pipeline scaffolding around a user's existing agent CLI or VS Code extension (Claude Code, Goose, Codex, …); +DSAgt connects an MCP-compatible AI coding agent to code registration, a semantic knowledge base, skills discovery and creation, execution provenance, and observability infrastructure. DSAgt provides data-pipeline scaffolding around a user's existing agent CLI or VS Code extension (Claude Code, Goose, Codex, …); -**Prerequisites:** Python 3.12 or 3.13, and one of the supported agent platforms below — already installed and authenticated against whatever LLM provider you intend to use. ([uv](https://github.com/astral-sh/uv) is only needed for the development install.) +**Prerequisites:** Python 3.12 or 3.13, and one of the supported agent platforms below — already installed and authenticated against whatever LLM provider you intend to use. | Agent | Install | Verify | @@ -15,7 +15,6 @@ DSAgt connects an MCP-compatible AI coding agent to tool registration, a semanti | [Goose](https://github.com/block/goose) | See [Goose docs](https://github.com/block/goose#installation) | `goose --version` | | [Codex](https://github.com/openai/codex) | `npm i -g @openai/codex` (or `brew install --cask codex`) | `codex --version` | | [opencode](https://github.com/sst/opencode) | See [opencode docs](https://opencode.ai/docs/) | `opencode --version` | -| [Roo Code](https://github.com/RooCodeInc/Roo-Code) | `npm i -g @roo-code/cli` | `roo --version` | | [Cline](https://github.com/cline/cline) | `npm i -g cline` | `cline --version` | @@ -24,7 +23,6 @@ DSAgt connects an MCP-compatible AI coding agent to tool registration, a semanti ### For use (no development) -If you just want to *run* DSAgt against your own data and agent — no repo checkout, no `uv` — install it straight from GitHub into a virtual environment. Any Python 3.12/3.13 environment works (`venv`, conda, etc.); only the `pip install git+…` step is officially supported. ```bash python3.12 -m venv ~/.venvs/dsagt # or: conda create -n dsagt python=3.12 && conda activate dsagt @@ -33,7 +31,7 @@ pip install "git+https://github.com/AI-ModCon/dsagt.git" dsagt --version # 0.2.0 ``` -This puts the `dsagt` CLI (and the `dsagt-run` / `dsagt-server` helpers) on your PATH. Create your first project — `dsagt init` is interactive (it walks you through the agent platform, project location, packaged knowledge collections, and skill-catalog sources) and provisions the shared knowledge base on first run: +This puts the `dsagt` CLI (and the `dsagt-run` / `dsagt-server` helpers) on your PATH. Create your first project — `dsagt init` is interactive (it walks you through the agent platform, project location, packaged knowledge collections, and skill-catalog sources) and sets up the knowledge base on first run: ```bash dsagt init # interactive; pick agent, collections, sources @@ -60,7 +58,7 @@ Clone the repo and use `uv` (editable install with the full test suite) — see ## Quick Start -Explore DSAgt knowledge ingest, tool registration, provenance, and explicit memory using the mock project in [`tests/smoke_test/`](tests/smoke_test/). Uses `claude`; substitute another agent (`goose` / `codex` / `opencode` / `roo` / `cline`) if you prefer — the prompts are agent-agnostic. +Explore DSAgt knowledge ingest, code registration, provenance, and explicit memory using the mock project in [`tests/smoke_test/`](tests/smoke_test/). Uses `claude`; substitute another agent (`goose` / `codex` / `opencode` / `cline`) if you prefer — the prompts are agent-agnostic. ```bash # 0. Install @@ -74,7 +72,7 @@ export SMOKE_DIR="$(pwd)/tests/smoke_test" # 1. Create a project called quickstart. Interactive `dsagt init` prompts for # the agent, location, knowledge collections, and skill sources, and -# provisions the shared knowledge base on first run (a ~130 MB local +# sets up the knowledge base on first run (a ~130 MB local # embedder downloads once). `--agent` makes it non-interactive: dsagt init quickstart --agent claude @@ -85,47 +83,47 @@ cd ~/dsagt-projects/quickstart && claude # …or: dsagt start quickstart Inside the agent, paste these prompts one at a time (substitute the absolute path you exported as `$SMOKE_DIR` — the chat doesn't expand env vars): 1. > Ingest the docs in `$SMOKE_DIR/knowledge/` into a collection named `knowledge`. -2. > Register the csvkit CLI tools `csvcut`, `csvgrep`, `csvstat`, and `csvlook`. -3. > Use the `scan_directory` tool from the registry to scan `$SMOKE_DIR/data/`. -4. > Summarize `samples.csv` — columns, row count, quality issues using csvkit tools from the registry. +2. > Register the csvkit CLI codes `csvcut`, `csvgrep`, `csvstat`, and `csvlook`. +3. > Use the `scan_directory` code from the registry to scan `$SMOKE_DIR/data/`. +4. > Summarize `samples.csv` — columns, row count, quality issues using csvkit codes from the registry. 5. > Put this in explicit memory: samples.csv has null values in the status and timestamp columns. 6. > Tell me what you remember about the samples dataset. -What this exercised: +This exercised: -| Prompt | Layer | +| Prompt | Capability | |---|---| | 1 | `dsagt-server` (`kb_ingest`) — chunks and indexes docs into ChromaDB | -| 2 | `dsagt-server` (`save_tool_spec`) — writes `tools/csvcut.md`, `tools/csvgrep.md`, etc. (one per registered tool) | -| 3 | `dsagt-run` provenance wrapper — records the execution layer to `trace_archive/` | +| 2 | `dsagt-server` (`save_code_spec`) — writes `codes/csvcut.md`, `codes/csvgrep.md`, etc. (one per registered code) | +| 3 | `dsagt-run` provenance wrapper — records the execution to `trace_archive/` | | 5–6 | Explicit memory (`kb_remember` → `.dsagt/explicit_memories.yaml`) + KB recall (`kb_get_memories`) | Exit the agent (`Ctrl+C` or `/exit`), then verify the artifacts and view traces: ```bash dsagt info quickstart # config + a session/trace summary -ls ~/dsagt-projects/quickstart/{tools,trace_archive} +ls ~/dsagt-projects/quickstart/{codes,trace_archive} cat ~/dsagt-projects/quickstart/.dsagt/explicit_memories.yaml -# Traces land in a serverless SQLite store — no server to run. Browse them with: +# Traces land in a serverless SQLite store. Browse them with: mlflow ui --backend-store-uri sqlite:///$HOME/dsagt-projects/quickstart/mlflow.db ``` -The same flow runs non-interactively via `dsagt smoke-test --agent claude` (or `goose` / `codex` / `opencode`), which asserts each artifact is present. +The same flow runs non-interactively via `dsagt smoke-test --agent claude` (or `goose` / `codex` / `opencode` / `cline`), which asserts each artifact is present. -### Knowledge base provisioning +### Knowledge base setup -`dsagt init` provisions the project's knowledge base. The shared, machine-wide collections live under `~/dsagt-projects/kb_index/` and are built once (the first project on a machine pays the cost), then copied into each project's `kb_index/`: +`dsagt init` sets up the project's knowledge base with three kinds of collection: -- **Tool Specs** — DSAgt's bundled tool specs from `src/dsagt/tools/`, tagged `source: bundled`, always provisioned so the agent finds them via `search_registry` from the first session. -- **Skill Catalogs** — the skill-catalog sources you check at init (default `genesis`) are cloned and frontmatter-indexed so `search_skills` returns installable skills. The bundled `skill-creator` is auto-discovered natively by the agent, not indexed. -- **Knowledge Collections** — optional reference corpora you check at init (`nemo_curator`, `aidrin`), downloaded and embedded for data-curation domain knowledge. +- **Code Specs** — DSAgt's bundled code specs, always set up so the agent finds them via `search_registry` from the first session. +- **Skill Catalogs** — the skill-catalog sources you pick at init (default `genesis`) are cloned and indexed so `search_skills` returns installable skills. The bundled `skill-creator` is discovered natively by the agent. +- **Knowledge Collections** — optional reference document sets you pick at init (`nemo_curator`, `aidrin`), downloaded and indexed for data-curation domain knowledge. The default embedder is a local sentence-transformers model (~130 MB of weights downloaded on first run, CPU-side, no API key). ## Use Case Examples -End-to-end walkthroughs for representative scientific domains live in [`use_cases/`](use_cases/). Each one covers data acquisition, tool registration, pipeline construction, and agent-driven execution against a real dataset. +End-to-end walkthroughs for representative scientific domains live in [`use_cases/`](use_cases/). Each one covers data acquisition, code registration, pipeline construction, and agent-driven execution against a real dataset. | Use case | Domain | Guide | |----------|--------|-------| @@ -142,7 +140,7 @@ dsagt init my-project --agent claude --location /data/runs # /data/runs/my-pro dsagt init my-project --agent claude --location . # ./my-project/ ``` -Projects are registered in `~/dsagt-projects/projects.yaml` so `dsagt info ` works from any directory. The data layer (knowledge base, trace store, registered tools, skills, audit records) is agent-agnostic, so re-running `dsagt init ` and choosing a different agent switches platforms while preserving everything you've accumulated (it prompts before any destructive change). +Projects are registered in `~/dsagt-projects/projects.yaml` so `dsagt info ` works from any directory. The project's data — knowledge base, trace store, registered codes, skills, audit records — is agent-agnostic, so re-running `dsagt init ` and choosing a different agent switches platforms while preserving everything you've accumulated (it prompts before any destructive change). ``` ~/dsagt-projects/cheese-metagenome/ @@ -150,10 +148,10 @@ Projects are registered in `~/dsagt-projects/projects.yaml` so `dsagt info /tools/`. Executables are wrapped with `dsagt-run` for provenance and `uv run --with` for Python dependencies. The agent discovers tools via `search_registry`. +- **Registry** — Code registration and dependency installation. Codes are markdown files with YAML frontmatter under `/codes/`. Executables are wrapped with `dsagt-run` for provenance and `uv run --with` for Python dependencies. The agent discovers codes via `search_registry`. - **Knowledge** — Semantic search over indexed ChromaDB document collections. Background jobs handle long ingest operations. The agent searches via `kb_search`, ingests via `kb_ingest`, and saves user-confirmed facts via `kb_remember`. -### Tools and Skills +### Codes and Skills -**Tools** are CLI executables defined as markdown files with YAML frontmatter in `/tools/`. The agent registers new tools via the MCP server's `save_tool_spec`. +**Codes** are CLI executables defined as markdown files with YAML frontmatter in `/codes/`. The agent registers new codes via the MCP server's `save_code_spec`. **Skills** are instruction-based agent workflows — a directory with a `SKILL.md` and optional reference docs. They come in two tiers: @@ -186,59 +183,57 @@ The catalog is **opt-in**: a source must be synced before its skills are searcha ![DSAgt skills routing](latex/skills-routing.png) -Skill handling runs through one service over two stores. **`SkillRouter`** is the single skill-MCP entry point — every skill tool routes through it: `add_skill_source` / `list_skill_sources` manage repos, `search_skills` queries the catalog, `install_skill` adopts a catalog skill into the project. **Registration** pulls skills from External Skills Repos (the curated `k-dense-ai` / `anthropic` / `antigravity` / `composio` / `genesis` sources, *or any git URL*) into the **Skills Catalog** — a federated, searchable store of *not-yet-installed* skills (semantic search, with a zero-dependency keyword fallback when no embedder is configured). **Discovery** is the catalog's irreplaceable job: surfacing skills the agent doesn't yet have, which native discovery can't see. **Progressive exposure** is native: the **Skill Directory** holds the project's installed + created skills in each agent's own skill dir (`.claude/skills`, `.agents/skills`, `.cline/skills`, `.roo/skills`), where the agent auto-discovers and model-invokes them by relevance — and authors new ones via the bundled **`skill-creator`** skill. The diagram source is [`latex/skills-routing.tex`](latex/skills-routing.tex). +The diagram traces a skill's lifecycle: **discovery** — browse the catalog with `search_skills` for skills the agent doesn't yet have → **install** — `install_skill` copies one into the project → **use** — the agent auto-discovers installed skills natively and invokes them by relevance (and authors new ones with the bundled `skill-creator`). The diagram source is [`latex/skills-routing.tex`](latex/skills-routing.tex). ### Knowledge Base -Six independently-partitioned ChromaDB collections hold everything the agent searches semantically. The first three are machine-wide (built once under `~/dsagt-projects/kb_index/` and copied into each project); the last three are per-project (under `/kb_index/`, filled automatically during use): +The agent searches these collections semantically: | Collection | Source | Populated by | |---|---|---| -| **Tool Specs** | Bundled CLI tool specs in `src/dsagt/tools/` | `dsagt init` (always provisioned) | -| **Skill Catalogs** | Installable skills from external repos (one `skills_catalog__` per source), frontmatter-indexed | `dsagt init` (chosen sources) + `add_skill_source` | -| **Knowledge Collections** | NeMo Curator + AIDRIN reference corpora; user-ingested docs | `dsagt init` (chosen collections) + agent's `kb_ingest` | +| **Code Specs** | Bundled CLI code specs | `dsagt init` (always set up) | +| **Skill Catalogs** | Installable skills from external repos (one per source) | `dsagt init` (chosen sources) + `add_skill_source` | +| **Knowledge Collections** | NeMo Curator + AIDRIN reference collections; user-ingested docs | `dsagt init` (chosen collections) + agent's `kb_ingest` | | **Explicit Memory** | User-confirmed facts | Agent's `kb_remember` (also written to `/.dsagt/explicit_memories.yaml`); the agent fetches via `kb_get_memories` on demand, not auto-loaded at session start | -| **Tool Use Records** | `dsagt-run` execution traces | `dsagt-run` writes JSON to `/trace_archive/`; embedded into ChromaDB incrementally by the MCP server's heartbeat (idempotent), and on demand before `reconstruct_pipeline` | -| **Episodic Memory** | Distilled session facts | **Opt-in** (`dsagt init --episodic`): the heartbeat distills each completed turn into tagged, ≤1-sentence facts via a local LLM judge (Tier-1), falling back to mechanical chunk+tag+embed (Tier-0) on judge failure. Retrieval is recency-weighted. | +| **Code Execution Records** | `dsagt-run` execution traces | `dsagt-run` writes JSON to `/trace_archive/`; indexed for search during the session, and before `reconstruct_pipeline` | +| **Episodic Memory** | Captured session turns | **Opt-in** (`dsagt init --episodic`): DSAgt captures each completed turn into `session_memory` during the session (mechanical chunk + embed). Retrieval is recency-weighted. | The embedding backend is local (sentence-transformers, CPU-side, no API key). -The agent searches via `kb_search` and writes via `kb_ingest` / `kb_remember`. Registered tools have their own `search_registry` route over the same backend. Installed skills are auto-discovered natively by the agent (not indexed); enabling external skill catalogs adds one `skills_catalog__` collection per source, which `search_skills` browses for installable skills. +The agent searches via `kb_search` and writes via `kb_ingest` / `kb_remember`. Registered codes have their own `search_registry` route over the same backend. Installed skills are discovered natively by the agent; enabling external skill catalogs adds one collection per source, which `search_skills` browses for installable skills. ### Memory DSAgt has two memory types, both retrievable via `kb_search` / `kb_get_memories`: - **Explicit memory** — user-confirmed facts the agent writes with `kb_remember` (mirrored to `/.dsagt/explicit_memories.yaml`). Always on; degrades to pure-YAML if the vector store is unavailable. -- **Episodic memory** — automatic session facts, **opt-in** (`dsagt init --episodic`, off by default). The MCP server's in-session heartbeat reads the agent's transcript and, each completed turn, distills it into a few tagged, ≤1-sentence facts in the `session_memory` collection. Two tiers: - - **Tier-1 (default when enabled)** — a small **local** LLM "judge" (`Qwen2.5-1.5B`, grammar-constrained) classifies each fact against a closed tag taxonomy (stock "AI-data-ready" tags + any project `--domain-tags`) and condenses it. Local-by-default — **no API key, no cost** — but the GGUF model (~1 GB) downloads on first use and inference uses CPU (~1 s per fact-bearing turn, off the agent's critical path). - - **Tier-0 (fallback)** — mechanical chunk + keyword-tag + embed, no LLM; used automatically if the judge fails, so a turn is never lost. +- **Episodic memory** — automatic session capture, **opt-in** (`dsagt init --episodic`, off by default). As the session runs, DSAgt reads the agent's transcript and captures each completed turn into the `session_memory` collection — a fast, local chunk-and-embed pass. - Retrieval is **recency-weighted** (`episodic.recency_half_life_days`, default 14): a newer fact edges out a stale one, but as a bounded boost — a strongly-relevant old fact is never buried. Enabling the local judge needs no setup beyond the bundled `llama-cpp-python` (a core dependency, installed from a prebuilt CPU wheel). + Retrieval is **recency-weighted** (`episodic.recency_half_life_days`): a newer turn edges out a stale one, but as a bounded boost — a strongly-relevant old turn is never buried. ### Observability -Self-logging goes to a serverless MLflow SQLite store at `/mlflow.db` — no server to run. Browse it with `mlflow ui --backend-store-uri sqlite:////mlflow.db`. The trace view shows: +Self-logging goes to a serverless MLflow SQLite store at `/mlflow.db`. Browse it with `mlflow ui --backend-store-uri sqlite:////mlflow.db`. The trace view shows: - **Knowledge base operations** — `kb.search` / `kb.embed` / `kb.index_search` / `kb.rerank` span trees with per-phase timing. -- **Tool executions** — `tool.execute` spans with exit code, duration, file counts, truncated stderr. Full payload in `trace_archive/.json`. -- **Registry events** — `save_tool_spec`, `install_dependencies`, `reconstruct_pipeline` spans. -- **Agent traces** — recovered post-hoc from the on-disk session transcript by the MCP server's in-session heartbeat (a per-agent reader → canonical-trace translator → MLflow sink), so prompts/responses/tool-calls land in the store for every supported agent, not just claude. (claude additionally wires an `mlflow autolog` Stop hook at `dsagt init`.) +- **Code executions** — `code.execute` spans with exit code, duration, file counts, truncated stderr. Full payload in `trace_archive/.json`. +- **Registry events** — `save_code_spec`, `install_dependencies`, `reconstruct_pipeline` spans. +- **Agent traces** — recovered from the on-disk session transcript, so prompts, responses, and tool calls land in the store for every supported agent. -The MCP server mints a session id per launch into `/.dsagt/state.yaml`, and every span carries it for filtering. Tool execution records on disk provide the canonical provenance chain — the agent calls `reconstruct_pipeline` to render the trace archive as a reproducible bash script or Snakemake workflow. +Each launch gets a session id that every span carries, so you can filter the trace view by session. The code-execution records on disk are the provenance record — the agent calls `reconstruct_pipeline` to render them as a reproducible bash script or Snakemake workflow. ## CLI Reference | Command | Description | |---------|-------------| -| `dsagt init []` | Create or reconfigure a project — interactive: agent, location, knowledge collections, skill sources, and the episodic-memory opt-in; provisions the KB and writes the per-agent MCP config | -| `dsagt init --agent [--location ] [--include … \| --exclude …] [--episodic [--domain-tags "a,b"]]` | Same, non-interactively (for scripts/CI); `--episodic` enables episodic memory (downloads the ~1 GB local judge on first use) | +| `dsagt init []` | Create or reconfigure a project — interactive: agent, location, knowledge collections, skill sources, and the episodic-memory opt-in; sets up the KB and writes the per-agent MCP config | +| `dsagt init --agent [--location ] [--include … \| --exclude …] [--episodic]` | Same, non-interactively (for scripts/CI); `--episodic` enables episodic memory (mechanical chunk + embed capture) | | `dsagt start ` | Launch the agent in the project directory (equivalent to `cd && `) | | `dsagt info [--json]` | Resolved config (with source per value) and a session/trace summary | | `dsagt list` | List all projects with agent and path | | `dsagt mv ` | Move a project to a new location | | `dsagt rm [-y] [--keep-files]` | Unregister a project (and optionally delete its directory) | -| `dsagt smoke-test [--agent claude\|goose\|codex\|opencode]` | End-to-end install verification | +| `dsagt smoke-test [--agent claude\|goose\|codex\|opencode\|cline]` | End-to-end install verification | Skill catalogs are managed from the agent via the MCP tools (`add_skill_source` / `search_skills` / `install_skill`), and traces are viewed with `mlflow ui --backend-store-uri sqlite:////mlflow.db`. diff --git a/docs/architecture.md b/docs/architecture.md index d416577..dcad5e6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,21 +2,27 @@ ![DSAgt architecture](assets/architecture.png) -DSAgt wraps an unmodified agent CLI with four independently-operable layers. The tool-registry and knowledge-base layers are exposed through one MCP server (`dsagt-server`); the agent discovers and invokes their capabilities through the standard MCP tool protocol. +DSAgt provides a preconfigured agent platform with augmented capabilities for AI-ready scientific data processing and curation. Its capabilities are designed to complement rather than compete with the fast-moving agent platforms it runs on — Claude Code, Codex, Cline, opencode, and Goose. Most are exposed to the agent through a central MCP server: skill discovery and installation, data-processing code execution with provenance, knowledge-base extension and retrieval, and explicit and cross-session memory. Others are spawned by the server and run in the background: observability through MLflow traces, episodic memory management, and vector-store indexing. DSAgt also ships a suite of agent skills for AI-ready data workflows. -## Layers +## Capabilities -**Tool Registry** (`dsagt-server`) -The agent registers CLI tools as markdown files with YAML frontmatter under `/tools/`. DSAgt handles dependency installation via `uv run --with` and wraps every execution with `dsagt-run` for provenance capture. The agent discovers tools via `search_registry`. +**Code Registry** (`dsagt-server`) +The agent registers CLI data processing codes as markdown files with YAML frontmatter under `/codes/`. DSAgt handles dependency installation via `uv run --with` and wraps every execution with `dsagt-run` for provenance capture. The agent discovers codes via `search_registry`. **Knowledge Base** (`dsagt-server`) -Hybrid semantic + BM25 search over six independently-partitioned ChromaDB collections, served by the same process as the tool registry (one shared embedder, one ChromaDB owner). Three collections are machine-wide (provisioned by `dsagt init`, shared across projects); three are per-project (filled automatically during use). Background jobs handle long ingest operations. The agent searches via `kb_search`, ingests via `kb_ingest`, and saves user-confirmed facts via `kb_remember`. Opt-in episodic memory distills each session turn into the per-project `session_memory` collection. +Hybrid semantic + BM25 search over ChromaDB collections partitioned by concern — code specs, skill catalogs, scientific documents, and per-project memory — served by the same process as the code registry (one shared embedder, one ChromaDB). A first `dsagt init` installs the bundled code specs and a default skill catalog; heavier scientific collections (NeMo Curator, AIDRIN) and additional catalogs are opt-in, and new collections can be added for the documents of a specific scientific endeavor. Long ingests run as background jobs. The agent searches via `kb_search`, ingests via `kb_ingest`, and saves user-confirmed facts via `kb_remember`. Opt-in episodic memory distills each session turn into the per-project `session_memory` collection. **Provenance** (`dsagt-run`) -A thin wrapper around every registered-tool execution. Records the command, arguments, exit code, duration, file counts, and truncated stderr to `/trace_archive/.json` and emits a `tool.execute` span to the trace store. The agent calls `reconstruct_pipeline` to render the trace archive as a reproducible bash script or Snakemake workflow. +A wrapper around every registered-code execution. Records the command, arguments, exit code, duration, file counts, and truncated stderr to `/trace_archive/.json` and emits a `code.execute` span to the trace store. The server incrementally embeds those records into a `code_use` collection so past executions are retrievable, and the agent calls `reconstruct_pipeline` to render the trace archive as a reproducible workflow. **Observability** (serverless MLflow) -Traces land in a serverless MLflow store — a SQLite file at `/mlflow.db`, with no server or daemon to run. DSAgt emits its own spans live; the agent's LLM-call traces are recovered post-hoc from the on-disk session transcript by the MCP server's in-session heartbeat (no proxy, no OTel routing, no credentials). View with `mlflow ui --backend-store-uri sqlite:////mlflow.db`. +Traces land in a serverless MLflow store — a SQLite file at `/mlflow.db`. DSAgt emits its own spans live; the agent's LLM-call traces are recovered post-hoc from the on-disk session transcript by the MCP server's in-session reader. View with `mlflow ui --backend-store-uri sqlite:////mlflow.db`. + +**Skills Discovery** +DSAgt exposes MCP tools to connect to external GitHub skill repositories and search them for skills that enhance scientific workflows. On top of the agent's own progressive disclosure of the skills already installed in its native skill folder, DSAgt maintains an extendable knowledge base of skills that can be searched and installed on demand — without flooding the agent's context window with skills it isn't using. The agent searches via `search_skills` and installs via `install_skill`. + +**Memory** +DSAgt adds two memory extensions that complement the host agent's own memory (which distills session facts into a managed set of Markdown files loaded into context). *Explicit memory* records user-confirmed facts as YAML (`kb_remember` / `kb_get_memories`). *Episodic memory* (opt-in) keeps a vector store of semantically chunked turn blocks and searches them by successive filtering — first to a session, then by regex over the query's key themes, then a final vector ranking of what remains — so long, multi-session history can augment agent context without the noisy retrieval that undifferentiated episodic accrual would otherwise produce. ## Project Layout @@ -26,10 +32,10 @@ Traces land in a serverless MLflow store — a SQLite file at `/mlflow. config.yaml # project configuration (set by dsagt init) state.yaml # session log + memory cursor (owned by the MCP server) explicit_memories.yaml # user-confirmed facts - tools/ # registered CLI tool specs (markdown + YAML frontmatter) - tools/code/ # agent-written tool scripts + codes/ # registered CLI code specs (markdown + YAML frontmatter) + codes/scripts/ # agent-written codes skills/ # agent skills (SKILL.md + reference docs) - trace_archive/ # tool execution records (JSON, from dsagt-run) + trace_archive/ # code execution records (JSON, from dsagt-run) mlflow.db # serverless MLflow SQLite trace store kb_index/ # knowledge base vector collections @@ -38,8 +44,7 @@ Traces land in a serverless MLflow store — a SQLite file at `/mlflow. # goose: goose.yaml, .goosehints # codex: AGENTS.md, .codex-data/config.toml # opencode: AGENTS.md, opencode.json - # roo: .roomodes, .roo/mcp.json # cline: .clinerules/, cline_mcp_settings.json ``` -Projects are registered in `~/dsagt-projects/projects.yaml` so `dsagt info ` works from any directory. The data layer is agent-agnostic — re-running `dsagt init --agent ` switches agent platforms while preserving all accumulated knowledge and traces. +Projects are registered in `~/dsagt-projects/projects.yaml` so `dsagt info ` works from any directory. The project's data is agent-agnostic — re-running `dsagt init --agent ` switches agent platforms while preserving all accumulated knowledge and traces. diff --git a/docs/cli.md b/docs/cli.md index f6b56e1..9b6f3f6 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -2,45 +2,35 @@ All commands are available after [installation](index.md#installation) and activating your virtual environment. -DSAgt is **BYOA (bring your own agent)**: the agent talks to its own LLM provider; DSAgt never interposes on that traffic. The trace store is **serverless** (a SQLite file per project) — there is no daemon to start or stop. - ## Project Management | Command | Description | |---------|-------------| -| `dsagt init []` | Create or reconfigure a project — interactive: agent platform, location, knowledge collections, skill sources, and the episodic-memory opt-in. Provisions the knowledge base (once per machine) and writes the per-agent instructions + MCP config. Re-runnable as a settings editor. | -| `dsagt init --agent [--location ] [--include … \| --exclude …] [--episodic [--domain-tags "a,b"]]` | Same, non-interactively (scripts/CI). `--include`/`--exclude` pick the KB asset set; `--episodic` enables episodic memory (downloads the ~1 GB local judge on first use). | +| `dsagt init []` | Create or reconfigure a project — interactive: agent platform, location, knowledge collections, skill sources, and the episodic-memory opt-in. Sets up the knowledge base and writes the per-agent instructions + MCP config. Re-runnable as a settings editor. | | `dsagt start ` | Launch the agent in the project directory (equivalent to `cd && `); on exit, runs post-session catch-up. | | `dsagt list` | List all projects with agent and path. | | `dsagt info [--json]` | Resolved config (with the source of each value) and a session/trace summary read from the SQLite store. | | `dsagt mv ` | Move a project to a new location. | | `dsagt rm [-y] [--keep-files]` | Unregister a project and optionally delete its directory. | -| `dsagt smoke-test [--agent claude\|goose\|codex\|opencode]` | End-to-end install verification. | - -Skill catalogs are managed **from the agent** via MCP tools (`add_skill_source` / `list_skill_sources` / `search_skills` / `install_skill`), not the CLI. +| `dsagt smoke-test [--agent claude\|goose\|codex\|opencode\|cline]` | End-to-end install verification. | ## Project Location -The default project location is `~/dsagt-projects//`. Override with `--location`: - -```bash -dsagt init my-project --agent claude --location /data/runs # /data/runs/my-project/ -dsagt init my-project --agent claude --location . # ./my-project/ -``` +The default project location is `~/dsagt-projects//`. ## Viewing traces -The trace store is a serverless SQLite file — browse it with MLflow's UI pointed at the file (no `dsagt` daemon involved): +The trace store is a serverless SQLite file — browse it with MLflow's UI pointed at the file: ```bash -mlflow ui --backend-store-uri sqlite:////mlflow.db +mlflow ui --backend-store-uri sqlite:////mlflow.db ``` ## Server Commands -These are launched automatically by the per-agent MCP config (and `dsagt start`) and are not typically run directly. +These are launched automatically by the per-agent MCP config and are not typically run directly. | Command | Description | |---------|-------------| -| `dsagt-server` | The single MCP server — tool registry, knowledge base, memory, and skills. Also runs the in-session heartbeat (trace capture + tool-use/episodic indexing). | -| `dsagt-run` | Provenance-capturing tool execution wrapper; writes execution records to `/trace_archive/`. | +| `dsagt-server` | The single MCP server — code registry, knowledge base, memory, and skills. Also runs the in-session heartbeat (trace capture + code-use/episodic indexing). | +| `dsagt-run` | Provenance-capturing code execution wrapper; writes execution records to `/trace_archive/`. | diff --git a/docs/developer.md b/docs/developer.md index 8b3703d..2ae0236 100644 --- a/docs/developer.md +++ b/docs/developer.md @@ -9,14 +9,8 @@ uv run python -m pytest -m "not integration" # unit tests, no creds required uv run python -m pytest -m integration -v # integration tests (require real credentials / models) ``` -Integration tests need real `EMBEDDING_*` / `LLM_*` credentials (and, for the episodic-memory judge test, the local GGUF model, downloaded on first run). Copy `.env.example` to `.env` and fill in your values where applicable. - For per-flow hand-tests (CLI, VS Code extensions), see the scripts under [`tests/smoke_test/manual_runs/`](https://github.com/AI-ModCon/dsagt/tree/main/tests/smoke_test/manual_runs/). -## Run model - -DSAgt is **BYOA (bring your own agent)**: the agent talks to its own LLM provider directly — DSAgt never interposes on that traffic (there is no proxy). Trace capture instead reads the agent's own on-disk session transcript via the MCP server's in-session heartbeat, so no credentials are required and the trace store stays serverless (a SQLite file per project). Agent LLM-call history is recovered post-hoc; nothing intercepts the network. - ## Troubleshooting **Agent command not found.** The agent CLI is not installed or is not on PATH. See the [supported agents table](index.md#supported-agents). @@ -27,17 +21,4 @@ DSAgt is **BYOA (bring your own agent)**: the agent talks to its own LLM provide uv run which dsagt-server ``` -If missing, reinstall: `pip install --force-reinstall "git+https://github.com/AI-ModCon/dsagt.git"`. - -**No traces / empty MLflow UI.** The store is a serverless SQLite file — there is no daemon to start. Point the UI at the file directly and confirm the path: - -```bash -dsagt info # shows the resolved tracking URI + a session/trace summary -mlflow ui --backend-store-uri sqlite:////mlflow.db -``` - -If the file is missing, the agent hasn't run a session in that project yet (the DB is created lazily on the first span). - -**Claude keychain conflict.** If `claude` will not authenticate against a non-default gateway, run `claude /logout` to clear the macOS Keychain OAuth token, then re-export `ANTHROPIC_BASE_URL` / `ANTHROPIC_API_KEY` and re-launch. - -**Episodic memory judge won't load.** Tier-1 episodic memory needs `llama-cpp-python` (a core dependency, installed from a prebuilt CPU wheel) and downloads a ~1 GB GGUF on first use. If it fails, the heartbeat falls back to Tier-0 (mechanical, no LLM) — episodic memory still works, just without LLM distillation. Re-run `uv sync` if the wheel is missing. +If missing, reinstall: `pip install --force-reinstall "git+https://github.com/AI-ModCon/dsagt.git"`. \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index 9041321..c2f8c4e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,7 +2,7 @@ **D**ata**S**mith **Ag**en**t** — AI-assisted data pipeline builder. -DSAgt connects an MCP-compatible AI coding agent to tool registration, a semantic knowledge base, execution provenance, and observability infrastructure. It provides data-pipeline scaffolding around your existing agent CLI or VS Code extension (Claude Code, Goose, Codex, and others). +DSAgt connects an MCP-compatible AI coding agent to code registration, a semantic knowledge base, execution provenance, and observability infrastructure. It provides data-pipeline scaffolding around your existing agent CLI or VS Code extension (Claude Code, Goose, Codex, and others). ## Supported Agents @@ -42,13 +42,14 @@ source .venv/bin/activate ## Key Capabilities -| Layer | What it does | +| Capability | What it does | |-------|-------------| -| **Tool Registry** | Register CLI tools as markdown specs; the agent discovers and runs them via `search_registry` | +| **Code Registry** | Register CLI codes as markdown specs; the agent discovers and runs them via `search_registry` | | **Knowledge Base** | Hybrid semantic + keyword (BM25) search over indexed ChromaDB collections | -| **Provenance** | `dsagt-run` wrapper records every tool execution to `trace_archive/`; `reconstruct_pipeline` renders it as a runnable script | +| **Skills Discovery** | Search external GitHub skill catalogs and install workflow skills on demand via `search_skills` / `install_skill`, without flooding the agent's context | +| **Provenance** | `dsagt-run` wrapper records every code execution to `trace_archive/`; `reconstruct_pipeline` renders it as a runnable script | | **Explicit Memory** | User-confirmed facts persisted to YAML and the knowledge base | -| **Episodic Memory** | Opt-in: the MCP server distills each session turn into tagged facts via a local LLM judge (recency-weighted retrieval) | +| **Episodic Memory** | Opt-in: the MCP server mechanically chunks and embeds each session turn into a searchable `session_memory` collection (recency-weighted retrieval) | | **Observability** | Serverless MLflow tracing (a per-project SQLite file) — DSAgt's own spans plus agent traces recovered from the on-disk transcript | See the [Quick Start](quickstart.md) to try all of these in a single session. diff --git a/docs/knowledge-base.md b/docs/knowledge-base.md index 89c6118..d02cccc 100644 --- a/docs/knowledge-base.md +++ b/docs/knowledge-base.md @@ -1,17 +1,17 @@ # Knowledge Base -DSAgt maintains six independently-partitioned ChromaDB collections. The first three are machine-wide (built once under `~/dsagt-projects/kb_index/` and copied into each project); the last three are per-project (under `/kb_index/`, populated automatically during use). All are provisioned/used through `dsagt init` and the agent's MCP tools — there is no separate setup command. +DSAgt maintains independently-partitioned ChromaDB collections, with six core DSAgt collections set up and used through `dsagt init` and the agent's MCP tools: ## Collections | Collection | Source | Populated by | |---|---|---| -| **Tool Specs** | Bundled CLI tool specs in `src/dsagt/tools/` | `dsagt init` (always provisioned) | -| **Skills Catalog** | Installable skills from external repos (one `skills_catalog__` collection per source), frontmatter-indexed | `dsagt init` (chosen sources) + `add_skill_source` | -| **Domain Knowledge** | NeMo Curator + AIDRIN reference corpora; user-ingested docs | `dsagt init` (chosen collections) + agent's `kb_ingest` | +| **Code Specs** | Bundled CLI code specs | `dsagt init` (always set up) | +| **Skills Catalog** | Installable skills from external repos (one per source) | `dsagt init` (chosen sources) + `add_skill_source` | +| **Domain Knowledge** | NeMo Curator + AIDRIN reference collections; user-ingested docs | `dsagt init` (chosen collections) + agent's `kb_ingest` | | **Explicit Memory** | User-confirmed facts | Agent's `kb_remember` (also written to `/.dsagt/explicit_memories.yaml`) | -| **Episodic Memory** (`session_memory`) | Distilled session facts | The MCP server's in-session heartbeat (opt-in; see below) | -| **Tool Use Records** (`tool_use`) | `dsagt-run` execution traces | `dsagt-run` writes JSON to `/trace_archive/`; the heartbeat embeds them incrementally (idempotent) | +| **Episodic Memory** (`session_memory`) | Chunked session turns | Captured during the session (opt-in; see below) | +| **Code Execution Records** (`code_use`) | `dsagt-run` execution traces | `dsagt-run` writes JSON to `/trace_archive/`; indexed for search during the session | ## Explicit Memory @@ -19,19 +19,16 @@ Explicit memories are facts the user confirms during a session. The agent saves ## Episodic Memory -Episodic memory is **opt-in** (`dsagt init --episodic`, off by default). When enabled, the MCP server's in-session heartbeat reads the agent's transcript and distills each completed turn into a few tagged, ≤1-sentence facts in the `session_memory` collection. Two tiers: +When enabled, DSAgt reads the agent's transcript as the session runs and captures each completed turn into the `session_memory` collection — a fast, local chunk-and-embed pass that reuses the same embedder as the rest of the knowledge base. -- **Tier-1 (default when enabled)** — a small **local** LLM judge (`Qwen2.5-1.5B`, grammar-constrained JSON) classifies each fact against a closed tag taxonomy (stock "AI-data-ready" tags plus any project `--domain-tags`) and condenses it. Local-by-default: no API key, no cost. The GGUF model (~1 GB) downloads on first use; inference is CPU-side. -- **Tier-0 (fallback)** — mechanical chunk + keyword-tag + embed, no LLM; used automatically if the judge fails, so a turn is never lost. - -Retrieval over `session_memory` is **recency-weighted** (`episodic.recency_half_life_days`, default 14): a newer fact edges out a stale one as a bounded boost, so a corrected fact wins without contradiction detection while durable old facts keep their relevance. Optional per-category outlier detection (`episodic.outlier_sensitivity`) can queue novel facts for review. +Retrieval over `session_memory` is filtered to session, and then regex over key query terms prior to a **recency-weighted** ranked semantic-vector retrieval: a newer turn edges out a stale one as a bounded boost, so a corrected fact wins by recency while a strongly-relevant old turn is never buried. ## Search -The agent searches all collections via `kb_search` and writes via `kb_ingest` / `kb_remember`. Registered tools have their own `search_registry` route over the same backend. Skills are discovered separately — installed ones natively by the agent, installable ones via `search_skills` over the external catalog (see [Tools & Skills](tools-skills.md)). +The agent searches all collections via `kb_search` and writes via `kb_ingest` / `kb_remember`. Registered codes have their own `search_registry` route over the same backend. Skills are discovered separately — installed ones natively by the agent, installable ones via `search_skills` over the external catalog (see [Codes & Skills](codes-skills.md)). -Hybrid search (dense embeddings + sparse BM25 via Reciprocal Rank Fusion) is on by default per collection. Cross-encoder reranking is optional. The default embedder is a local sentence-transformers model (~130 MB, CPU-side, no API key). +Hybrid search (semantic embeddings + keyword BM25) is on by default per collection. Optional reranking sharpens the top results. The default embedder is a local sentence-transformers model (~130 MB, CPU-side, no API key). -## Provisioning +## Setup -`dsagt init` provisions the KB. The shared, machine-wide collections are built once (the first project on a machine pays the cost) under `~/dsagt-projects/kb_index/`, then copied into each project. `--include` / `--exclude` (asset names, or `all`) select which collections to provision; the bundled Tool Specs collection is always included. Re-running `dsagt init` on an existing project reconfigures it in place. +`dsagt init` sets up the KB. `--include` / `--exclude` (asset names, or `all`) select which collections to include; the bundled Code Specs collection is always included. Re-running `dsagt init` on an existing project reconfigures it in place. diff --git a/docs/mcp-servers.md b/docs/mcp-servers.md index f04789a..7edd9e4 100644 --- a/docs/mcp-servers.md +++ b/docs/mcp-servers.md @@ -1,21 +1,21 @@ # MCP Server -DSAgt exposes its capabilities through a single MCP server, **`dsagt-server`**, configured in the per-agent runtime file (`.mcp.json` for Claude Code, `goose.yaml` for Goose, etc.) and launched automatically when the agent starts. It bundles four concern areas — a tool registry, a knowledge base, explicit memory, and skill discovery — behind one process with one shared embedder and one ChromaDB owner. +DSAgt exposes its capabilities through a single MCP server, **`dsagt-server`**, configured in the per-agent runtime file (`.mcp.json` for Claude Code, `goose.yaml` for Goose, etc.) and launched automatically when the agent starts. It bundles four capabilities — a code registry, a knowledge base, explicit memory, and skill discovery — behind one process with one shared embedder and one ChromaDB. > Earlier versions ran two separate servers (`dsagt-registry-server` + `dsagt-knowledge-server`), merged in 0.2.0. Re-run `dsagt start ` on an existing project to regenerate its config against the single server (for cline, delete `/.cline-data` first). ## Registry tools -Tool registration, dependency installation, and tool discovery. +Code registration, dependency installation, and code discovery. | Tool | Description | |------|-------------| -| `search_registry` | Semantic search over registered tool specs | -| `save_tool_spec` | Register a new CLI tool as a markdown file with YAML frontmatter | -| `install_dependencies` | Install tool dependencies via `uv run --with` | +| `search_registry` | Semantic search over registered code specs | +| `save_code_spec` | Register a new CLI code as a markdown file with YAML frontmatter | +| `install_dependencies` | Install code dependencies via `uv run --with` | | `reconstruct_pipeline` | Render the trace archive as a bash script or Snakemake workflow | -Tools are markdown files with YAML frontmatter under `/tools/`. Executables are wrapped with `dsagt-run` for provenance and `uv run --with` for Python dependencies. +Codes are markdown files with YAML frontmatter under `/codes/`. Executables are wrapped with `dsagt-run` for provenance and `uv run --with` for Python dependencies. ## Knowledge tools diff --git a/docs/observability.md b/docs/observability.md index 1228c6a..024898e 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -1,39 +1,39 @@ # Observability -DSAgt logs traces to a **serverless MLflow store** — a SQLite file at `/mlflow.db`. There is no server or daemon to start or stop; the file is created lazily on the first span. View it with MLflow's UI pointed at the file: +DSAgt logs traces to a **serverless MLflow store** — a SQLite file at `/mlflow.db`, created lazily on the first span. View it with MLflow's UI pointed at the file: ```bash mlflow ui --backend-store-uri sqlite:////mlflow.db ``` -`dsagt info ` prints the resolved tracking URI and a session/trace summary. The tracking URI resolves as `MLFLOW_TRACKING_URI` env → project config → the `sqlite:////mlflow.db` default, and never raises. +`dsagt info ` prints the resolved tracking URI and a session/trace summary. The tracking URI resolves as `MLFLOW_TRACKING_URI` env → project config → the `sqlite:////mlflow.db` default. ## Two feeds -DSAgt is **BYOA** and does not interpose on the agent's LLM traffic. Traces come from two places: +DSAgt is **BYOA**: the agent talks to its own LLM provider directly, and DSAgt reconstructs traces from what the agent writes to disk. Traces come from two places: 1. **First-party spans (live).** DSAgt instruments its own code and emits spans directly to the store as it runs. -2. **Agent traces (post-hoc).** The MCP server's in-session heartbeat reads the agent's own on-disk session transcript, translates it to a canonical trace shape, and writes it to the same store via the MLflow sink — so prompts, responses, and tool calls are recovered without any proxy or credentials. +2. **Agent traces (post-hoc).** The MCP server's in-session heartbeat reads the agent's own on-disk session transcript, translates it to a canonical trace shape, and writes it to the same store via the MLflow sink — recovering prompts, responses, and tool calls. ## Trace Coverage | Source | Span type | Contents | |--------|-----------|----------| | Knowledge base | `kb.search`, `kb.embed`, `kb.index_search`, `kb.rerank` | Per-phase timing trees | -| Tool executions | `tool.execute` | Exit code, duration, file counts, truncated stderr. Full payload in `trace_archive/.json` | -| Registry events | `save_tool_spec`, `install_dependencies`, `reconstruct_pipeline` | Span metadata | +| Code executions | `code.execute` | Exit code, duration, file counts, truncated stderr. Full payload in `trace_archive/.json` | +| Registry events | `save_code_spec`, `install_dependencies`, `reconstruct_pipeline` | Span metadata | | Agent traces | one AGENT subtree per turn (`llm` / `tool_` children) | Prompts, responses, tool calls, and token usage where the transcript carries them | ### Agent trace coverage -Agent traces are reconstructed from each agent's on-disk session record, so coverage no longer depends on the agent emitting OTel. A per-agent reader + translator runs for every supported agent (claude, codex, goose, opencode, cline); claude additionally wires an `mlflow autolog` Stop hook at `dsagt init`. Fidelity is capped by what the transcript persisted (e.g. token counts and timing appear where the agent recorded them). +Agent traces are reconstructed from each agent's on-disk session record. A per-agent reader + translator runs for every supported agent (claude, codex, goose, opencode, cline), uniformly. Fidelity is capped by what the transcript persisted (e.g. token counts and timing appear where the agent recorded them). Every span carries the project's session id (minted per launch into `/.dsagt/state.yaml`) for filtering in the MLflow trace view. ## The heartbeat -The trace scan runs as a periodic heartbeat inside the long-lived MCP server — the one DSAgt process alive in every launch flow. Each tick reads new transcript records, translates completed turns, and fans out to subscribers (the MLflow sink always; the episodic-memory extractor when enabled). Correctness rests on idempotency: each subscriber keeps its own ack set, so a re-tick or a next-session catch-up can never double-log or lose a turn. The same heartbeat incrementally indexes `dsagt-run` tool-execution records into the `tool_use` collection. +The trace scan runs as a periodic heartbeat inside the long-lived MCP server — the one DSAgt process alive in every launch flow. Each tick reads new transcript records, translates completed turns, and fans out to subscribers (the MLflow sink always; the episodic-memory extractor when enabled). Correctness rests on idempotency: each subscriber keeps its own ack set, so a re-tick or a next-session catch-up can never double-log or lose a turn. The same heartbeat incrementally indexes `dsagt-run` code-execution records into the `code_use` collection. ## Provenance and Reconstruction -Tool execution records on disk (`trace_archive/.json`) provide the canonical provenance chain. The agent calls `reconstruct_pipeline` to render the archive as a reproducible bash script or Snakemake workflow (which also flushes the latest tool-use records into the searchable index first). +Code execution records on disk (`trace_archive/.json`) provide the canonical provenance chain. The agent calls `reconstruct_pipeline` to render the archive as a reproducible bash script or Snakemake workflow (which also flushes the latest code-use records into the searchable index first). diff --git a/docs/quickstart.md b/docs/quickstart.md index 3b0deff..0f6571e 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -1,8 +1,8 @@ # Quick Start -This guide walks through knowledge ingest, tool registration, provenance, and explicit memory using the mock project in [`tests/smoke_test/`](https://github.com/AI-ModCon/dsagt/tree/main/tests/smoke_test/). The examples use `claude`; substitute another agent (`goose`, `codex`, `opencode`, `roo`, `cline`) if you prefer — the prompts are agent-agnostic. +This guide walks through knowledge ingest, code registration, provenance, and explicit memory using the mock project in [`tests/smoke_test/`](https://github.com/AI-ModCon/dsagt/tree/main/tests/smoke_test/). The examples use `claude`; substitute another agent (`goose`, `codex`, `opencode`, `cline`) if you prefer — the prompts are agent-agnostic. -DSAgt is **BYOA**: your agent talks to its own LLM provider. There is **no proxy and no MLflow daemon** — the trace store is a serverless SQLite file per project. +DSAgt is **BYOA**: your agent talks to its own LLM provider directly, and the trace store is a serverless SQLite file per project. ## Setup @@ -15,7 +15,7 @@ export SMOKE_DIR="$(pwd)/tests/smoke_test" # 1. Create a project called quickstart. Interactive `dsagt init` prompts for the # agent, location, knowledge collections, skill sources, and episodic memory, -# and provisions the shared knowledge base on first run. --agent makes it +# and sets up the knowledge base on first run. --agent makes it # non-interactive (a ~130 MB local embedder downloads once): dsagt init quickstart --agent claude @@ -28,20 +28,20 @@ cd ~/dsagt-projects/quickstart && claude # …or: dsagt start quickstart Inside the agent, paste these prompts one at a time. Replace `$SMOKE_DIR` with the absolute path you exported — the chat does not expand shell variables. 1. > Ingest the docs in `$SMOKE_DIR/knowledge/` into a collection named `knowledge`. -2. > Register the csvkit CLI tools `csvcut`, `csvgrep`, `csvstat`, and `csvlook`. -3. > Use the `scan_directory` tool from the registry to scan `$SMOKE_DIR/data/`. -4. > Summarize `samples.csv` — columns, row count, quality issues using csvkit tools from the registry. +2. > Register the csvkit CLI codes `csvcut`, `csvgrep`, `csvstat`, and `csvlook`. +3. > Use the `scan_directory` code from the registry to scan `$SMOKE_DIR/data/`. +4. > Summarize `samples.csv` — columns, row count, quality issues using csvkit codes from the registry. 5. > Put this in explicit memory: samples.csv has null values in the status and timestamp columns. 6. > Tell me what you remember about the samples dataset. -## What Was Exercised +## Capabilities Covered -| Prompt | DSAgt layer | +| Prompt | DSAgt capability | |--------|-------------| | 1 | `dsagt-server` (`kb_ingest`) — chunks and indexes docs into ChromaDB | -| 2 | `dsagt-server` (`save_tool_spec`) — writes `tools/csvcut.md`, etc. | -| 3 | `dsagt-run` provenance wrapper — records exec layer to `trace_archive/` | -| 4 | KB recall via `kb_search` and registered tool execution | +| 2 | `dsagt-server` (`save_code_spec`) — writes `codes/csvcut.md`, etc. | +| 3 | `dsagt-run` provenance wrapper — records the execution to `trace_archive/` | +| 4 | KB recall via `kb_search` and registered code execution | | 5–6 | Explicit memory (`kb_remember` → `.dsagt/explicit_memories.yaml`) + `kb_get_memories` | ## Verify the Artifacts @@ -50,10 +50,10 @@ Exit the agent (`Ctrl+C` or `/exit`), then: ```bash dsagt info quickstart # config + a session/trace summary -ls ~/dsagt-projects/quickstart/{tools,trace_archive} +ls ~/dsagt-projects/quickstart/{codes,trace_archive} cat ~/dsagt-projects/quickstart/.dsagt/explicit_memories.yaml -# Traces land in a serverless SQLite store — no server to run. Browse them with: +# Traces land in a serverless SQLite store. Browse them with: mlflow ui --backend-store-uri sqlite:///$HOME/dsagt-projects/quickstart/mlflow.db ``` @@ -65,22 +65,22 @@ The same flow runs non-interactively and asserts each artifact is present: dsagt smoke-test --agent claude ``` -## Knowledge Base Provisioning +## Knowledge Base Setup -`dsagt init` provisions the project's knowledge base. The shared, machine-wide collections live under `~/dsagt-projects/kb_index/`, built once (the first project on a machine pays the cost) and copied into each project: +`dsagt init` sets up the project's knowledge base with three kinds of collection: -- **Tool Specs** — DSAgt's bundled tool specs from `src/dsagt/tools/`, always provisioned so the agent finds them via `search_registry` from the first session. -- **Skill Catalogs** — the skill-catalog sources you chose at init (default `genesis`), cloned and frontmatter-indexed so `search_skills` returns installable skills. -- **Knowledge Collections** — optional reference corpora you chose at init (`nemo_curator`, `aidrin`). +- **Code Specs** — DSAgt's bundled code specs, always set up so the agent finds them via `search_registry` from the first session. +- **Skill Catalogs** — the skill-catalog sources you chose at init (default `genesis`), cloned and indexed so `search_skills` returns installable skills. +- **Knowledge Collections** — optional reference document sets you chose at init (`nemo_curator`, `aidrin`). `--include` / `--exclude` (asset names, or `all`) select the set non-interactively. The default embedder is a local sentence-transformers model (~130 MB, CPU-side, no API key). ## Optional: Episodic Memory -Pass `--episodic` at init (or choose it in the interactive prompt) to have the MCP server distill each session turn into searchable facts: +Pass `--episodic` at init (or choose it in the interactive prompt) to have the MCP server capture each session turn into a searchable `session_memory` collection: ```bash -dsagt init quickstart --agent claude --episodic --domain-tags "genomics,qc" +dsagt init quickstart --agent claude --episodic ``` -This downloads a small local LLM judge (~1 GB GGUF) on first use — no API key. See [Knowledge Base → Episodic Memory](knowledge-base.md#episodic-memory). +Capture is mechanical (chunk + embed) and reuses the local embedder, so there's nothing extra to download. See [Knowledge Base → Episodic Memory](knowledge-base.md#episodic-memory). diff --git a/docs/tools-skills.md b/docs/tools-skills.md index 1eafce8..e45db13 100644 --- a/docs/tools-skills.md +++ b/docs/tools-skills.md @@ -1,15 +1,15 @@ -# Tools and Skills +# Codes and Skills -## Tools +## Codes -Tools are CLI executables defined as markdown files with YAML frontmatter under `/tools/`. The agent registers new tools via the MCP server's `save_tool_spec` tool. +Codes are CLI executables defined as markdown files with YAML frontmatter under `/codes/`. The agent registers new codes via the MCP server's `save_code_spec` tool. -A tool spec includes: +A code spec includes: - A YAML frontmatter block describing the command, arguments, dependencies, and tags. - A markdown body with usage examples and notes for the agent. -Example tool spec structure: +Example code spec structure: ```markdown --- @@ -24,11 +24,11 @@ Prints descriptive statistics for all columns in a CSV file. Usage: csvstat [options] [FILE] ``` -DSAgt wraps every registered tool with `dsagt-run` for provenance capture and `uv run --with` for Python dependencies, so the agent can call any tool without managing environments manually. +DSAgt wraps every registered code with `dsagt-run` for provenance capture and `uv run --with` for Python dependencies, so the agent can call any code without managing environments manually. -### Bundled Tools +### Bundled Codes -DSAgt ships a `scan_directory` tool in `src/dsagt/tools/` that is indexed into the shared Tool Specs collection by `dsagt init` (always provisioned). +DSAgt ships a `scan_directory` code that is indexed into the Code Specs collection by `dsagt init` (always set up). ## Skills @@ -40,15 +40,15 @@ Skills are instruction-based agent workflows in `/skills/`. Each skill Skills live in **two tiers**, and a single MCP service — the **SkillRouter** — is the one entry point that routes every skill operation between them: -- **Catalog tier** — skills that exist in external repositories but are *not yet installed*. DSAgt federates many sources (`scientific`, `anthropic`, `antigravity`, `composio`, `genesis`, or any git URL); each is sparse-cloned and indexed into its own per-source ChromaDB collection. The agent browses this tier with `search_skills` and manages sources with `add_skill_source` / `list_skill_sources`. -- **Installed + created tier** — skills drawn into the project's Skill Directory (`/skills/`), either installed from the catalog (`install_skill`) or authored in place (e.g. with the bundled `skill-creator`). At `dsagt start` these are mirrored into each agent's *native* skill directory (`.claude/`, `.agents/`, `.cline/`, `.roo/`), where the agent auto-discovers and auto-invokes them. +- **Catalog tier** — skills that exist in external repositories but are *not yet installed*. DSAgt federates many sources (`k-dense-ai`, `anthropic`, `antigravity`, `composio`, `genesis`, or any git URL); each is cloned and indexed into its own collection. The agent browses this tier with `search_skills` and manages sources with `add_skill_source` / `list_skill_sources`. +- **Installed + created tier** — skills drawn into the project's Skill Directory (`/skills/`), either installed from the catalog (`install_skill`) or authored in place (e.g. with the bundled `skill-creator`). At `dsagt start` these are mirrored into each agent's *native* skill directory (`.claude/`, `.agents/`, `.cline/`), where the agent auto-discovers and auto-invokes them. The diagram's three bands trace a skill's lifecycle: **Discovery** (the router) → **Registration** (the searchable catalog) → **Progressive Exposure** (the native Skill Directory the agent loads on its own). -#### Why this shape +#### Design motivation -- **Search is catalog-only.** Every supported agent (Claude, Codex, Goose, Cline, Roo) natively auto-discovers `SKILL.md` folders, so installed skills never need to be indexed or returned by a tool — the harness already loads them. That frees `search_skills` to do the one thing native discovery cannot: browse a catalog of potentially thousands of *un*installed skills without holding them all in context. Catalogs are indexed on frontmatter (name + description + tags), which keeps those summaries compact and avoids diluting the embedding with full SKILL.md bodies. -- **Keyword fallback, no embedder required.** When no embedding model is configured, the router falls back to a zero-dependency token-overlap scorer over the local clone cache, so `search_skills` still works (just less fuzzy) — no model download, no API key. +- **Search is catalog-only.** Every supported agent (Claude, Codex, Goose, Cline, opencode) natively auto-discovers `SKILL.md` folders, so installed skills never need to be indexed or returned by a tool — the harness already loads them. So `search_skills` handles what native discovery can't reach: a catalog of potentially thousands of *un*installed skills, searchable without holding them all in context. Catalogs are indexed on name, description, and tags, which keeps those summaries compact and avoids diluting the embedding with full SKILL.md bodies. +- **Keyword fallback, no embedder required.** When no embedding model is configured, `search_skills` falls back to a keyword match over the local clones, so it still works (just less fuzzy) — no model or API key needed. - **One router, not scattered policy.** Backend selection (semantic vs. keyword), the catalog/installed split, and source bookkeeping all live in the SkillRouter rather than being re-implemented at each MCP and CLI call site, so the behavior can't drift between them. - **Federated and provenance-preserving.** Each source is an independent per-source collection, so re-syncing one never disturbs another; installing a catalog skill preserves its upstream `LICENSE`/`NOTICE` and stamps a `PROVENANCE.txt` into the installed directory. diff --git a/pyproject.toml b/pyproject.toml index a0f5762..5a37c69 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,24 +27,11 @@ dependencies = [ "sentence-transformers==5.4.0", # local embeddings & reranking "transformers==4.47.0", # pin for sentence-transformers "rank-bm25>=0.2.2", # sparse keyword retrieval - # Local GGUF judge for episodic Tier-1 distillation (the default Judge - # backend; see memory-plan.md §3 — under BYOA, DSAGT holds no LLM key, so a - # local model is the only zero-config option). Pinned EXACTLY on purpose: - # abetlen's self-hosted wheels are the only prebuilt source (PyPI is - # sdist-only → would compile llama.cpp), and they are intermittently - # zip-corrupt in a way uv hard-rejects (0.3.25/.27/.28/.30 all fail; - # extraction errors like "Bad uncompressed size"). 0.3.29 verified intact - # on macOS arm64, manylinux x86_64, and win_amd64 (2026-06-28) — RE-VERIFY - # integrity across those platforms before bumping this pin. No macOS - # x86_64 wheel exists upstream at any version, so Intel Macs are excluded by - # the marker (no LocalJudge there; Tier-0 mechanical memory still works). - # CPU wheel index + CUDA-HPC opt-in are wired in [tool.uv] below. - "llama-cpp-python==0.3.29; sys_platform != 'darwin' or platform_machine != 'x86_64'", ] [project.scripts] dsagt = "dsagt.commands.cli:main" -dsagt-run = "dsagt.commands.run_tool:main" +dsagt-run = "dsagt.commands.run_code:main" dsagt-server = "dsagt.mcp.server:main" [dependency-groups] @@ -72,7 +59,7 @@ version = {attr = "dsagt.__version__"} where = ["src"] [tool.setuptools.package-data] -dsagt = ["py.typed", "tools/*.md", "tools/*.py", "skills/**/*", "dsagt_instructions.md"] +dsagt = ["py.typed", "codes/*.md", "codes/*.py", "skills/**/*", "dsagt_instructions.md"] [tool.pytest.ini_options] testpaths = ["tests"] @@ -102,22 +89,3 @@ required-environments = [ constraint-dependencies = [ "numpy<2.0; sys_platform == 'darwin' and platform_machine == 'x86_64'", ] - -# llama-cpp-python ships prebuilt wheels only at this self-hosted index (PyPI -# carries the sdist alone → a source build needing cmake + a C compiler). Point -# uv at the CPU wheel index for this one package so ``uv sync`` fetches a binary -# on macOS arm64, Linux x86_64 (incl. HPC nodes), and Windows amd64. -# -# GPU on CUDA HPC is an opt-in (uv maps a package to one index, and the CPU -# wheel is the cross-platform baseline): reinstall the CUDA build with e.g. -# uv pip install llama-cpp-python==0.3.29 \ -# --index-url https://abetlen.github.io/llama-cpp-python/whl/cu124 -# (match cuNNN to the node's toolkit). CPU inference of a 1.5B judge on the -# batched, background heartbeat is fine, so GPU stays optional. -[[tool.uv.index]] -name = "llama-cpp-cpu" -url = "https://abetlen.github.io/llama-cpp-python/whl/cpu" -explicit = true - -[tool.uv.sources] -llama-cpp-python = { index = "llama-cpp-cpu" } diff --git a/src/dsagt/__init__.py b/src/dsagt/__init__.py index 154b57b..f52d3ec 100644 --- a/src/dsagt/__init__.py +++ b/src/dsagt/__init__.py @@ -27,6 +27,6 @@ _os.environ.setdefault("TOKENIZERS_PARALLELISM", "false") del _os, _default_threads -from dsagt.registry import ToolRegistry +from dsagt.registry import CodeRegistry -__all__ = ["ToolRegistry", "__version__"] +__all__ = ["CodeRegistry", "__version__"] diff --git a/src/dsagt/agents/__init__.py b/src/dsagt/agents/__init__.py index d9c45f2..a6957da 100644 --- a/src/dsagt/agents/__init__.py +++ b/src/dsagt/agents/__init__.py @@ -2,7 +2,7 @@ Agent platform configuration and launch. Generates platform-specific config files (MCP server entries, agent -instructions, env vars) from the single ``dsagt_config.yaml``. Launches +instructions, env vars) from the single ``.dsagt/config.yaml``. Launches the agent process in the foreground and blocks until it exits. BYOA: each agent talks directly to its own provider. DSAGT forces no diff --git a/src/dsagt/agents/base.py b/src/dsagt/agents/base.py index e32899d..4295808 100644 --- a/src/dsagt/agents/base.py +++ b/src/dsagt/agents/base.py @@ -44,9 +44,7 @@ "install_dependencies", "install_skill", "kb_append", - "kb_dismiss_suggestion", "kb_get_memories", - "kb_get_suggestions", "kb_ingest", "kb_job_status", "kb_list_collections", @@ -57,7 +55,7 @@ "reconstruct_pipeline", "run_command", "save_skill", - "save_tool_spec", + "save_code_spec", "search_registry", "search_skills", ] @@ -72,7 +70,7 @@ def _mcp_server_args() -> list[str]: """Build the argv tail for ``uv run dsagt-server``. The single merged server reads all configuration from the project's - ``dsagt_config.yaml`` (located via cwd-walk) — no CLI args needed. + ``.dsagt/config.yaml`` (located via cwd-walk) — no CLI args needed. """ return ["run", "dsagt-server"] @@ -280,7 +278,7 @@ class AgentSetup(ABC): Class attributes (set by every subclass): - - ``name`` — agent identifier as used in ``dsagt_config.yaml`` + - ``name`` — agent identifier as used in ``.dsagt/config.yaml`` (e.g. ``"claude"``, ``"goose"``). - ``base_command`` — argv list that launches the agent interactively. Subclasses may override :meth:`interactive_command` diff --git a/src/dsagt/agents/claude.py b/src/dsagt/agents/claude.py index 403f0af..4f26a24 100644 --- a/src/dsagt/agents/claude.py +++ b/src/dsagt/agents/claude.py @@ -2,7 +2,7 @@ Claude Code agent setup. Install: ``npm i -g @anthropic-ai/claude-code``. -Generates: ``.mcp.json``, ``CLAUDE.md``, ``.claude/settings.json``. +Generates: ``CLAUDE.md`` (instructions) and ``.mcp.json`` (MCP config). The user brings ``ANTHROPIC_API_KEY`` (and optionally ``ANTHROPIC_MODEL``, ``ANTHROPIC_BASE_URL``) themselves and Claude Code talks directly to its diff --git a/src/dsagt/agents/codex.py b/src/dsagt/agents/codex.py index 96d6306..c097e91 100644 --- a/src/dsagt/agents/codex.py +++ b/src/dsagt/agents/codex.py @@ -33,12 +33,10 @@ * Tool *results* go to ``codex.tool_result`` log events with full args + output (``session_telemetry.rs:962-1000``). -Conversation history *is* recoverable from +Conversation history is recovered from ``$CODEX_HOME/sessions/rollout--.jsonl`` (full assistant text -+ tool calls + responses). Reading that side-channel into -``extract_session`` is a TODO — not yet wired. Until then, memory -extraction will see only token counts and tool names from Codex -turns. ++ tool calls + responses) by the trace pipeline's Codex reader/translator +on the heartbeat — feeding MLflow and episodic memory like every other agent. Open Codex issues tracking richer OTel: openai/codex#12913, #10277, #6153, #16248. diff --git a/src/dsagt/tools/scan_directory.md b/src/dsagt/codes/scan_directory.md similarity index 94% rename from src/dsagt/tools/scan_directory.md rename to src/dsagt/codes/scan_directory.md index 2f4eb6e..bd93ac8 100644 --- a/src/dsagt/tools/scan_directory.md +++ b/src/dsagt/codes/scan_directory.md @@ -2,7 +2,7 @@ name: scan_directory description: Scan a data directory and produce structured report with file counts, sizes, and directory tree -executable: dsagt-run --tool scan_directory -- python -m dsagt.tools.scan_directory +executable: dsagt-run --code scan_directory -- python -m dsagt.codes.scan_directory parameters: directory: type: string diff --git a/src/dsagt/tools/scan_directory.py b/src/dsagt/codes/scan_directory.py similarity index 100% rename from src/dsagt/tools/scan_directory.py rename to src/dsagt/codes/scan_directory.py diff --git a/src/dsagt/commands/cli.py b/src/dsagt/commands/cli.py index 16b620b..3bfab73 100644 --- a/src/dsagt/commands/cli.py +++ b/src/dsagt/commands/cli.py @@ -145,46 +145,31 @@ def _skills_block_for(source_names: list[str]) -> dict: return {"sources": sources} -def _parse_domain_tags(raw: str) -> dict[str, str]: - """Comma-separated tag names → ``{name: name}`` (self-described closed-set tags). - - Domain tags merge onto the stock taxonomy in :class:`MemoryExtractor`; the - name doubles as the description shown to the judge (no separate prompt for - descriptions — keeps init to one free-text line per the plan §4). - """ - return {t: t for t in (p.strip() for p in raw.split(",")) if t} - - -def _episodic_block(enabled: bool, domain_tags: dict[str, str]) -> dict | None: +def _episodic_block(enabled: bool) -> dict | None: """The ``episodic`` config block, or ``None`` when the user didn't opt in. - Enabling defaults the Tier-1 ``local`` judge (LocalJudge distills facts; the - first heartbeat downloads ~1GB) — the chosen init default. ``None`` keeps a - disabled project's config minimal (``enabled: false`` is backfilled on read). + Enabling captures each completed turn into ``session_memory`` (mechanical + chunk + tag + embed). ``None`` keeps a disabled project's config minimal + (``enabled: false`` is backfilled on read). """ if not enabled: return None - return { - "enabled": True, - "judge": {"backend": "local", "model": ""}, - "domain_tags": domain_tags, - "outlier_sensitivity": 0.0, - } + return {"enabled": True} def _collect_settings(args, interactive: bool, existing: dict, pdir: Path | None): """Resolve the init choices (the 1:1 mirror of the config). Selection questions: agent platform, packaged KB document *collections*, - skill-catalog *sources*, and the episodic-memory opt-in (+ its domain tags). - The bundled ``tools`` collection is always provisioned and is NOT a - per-project choice. Project name + folder location are resolved by the - caller. Embedding / chunk_size / rerank are code defaults, not init choices. - - Interactive: questionary select/checkbox menus + y/N + free-text, pre-filled - with the project's current choices on re-init. Non-interactive (no TTY): - drive from ``--include`` / ``--exclude`` / ``--episodic`` / ``--domain-tags`` - flags — the automation/test path. + skill-catalog *sources*, and the episodic-memory opt-in. The bundled + ``tools`` collection is always provisioned and is NOT a per-project choice. + Project name + folder location are resolved by the caller. Embedding / + chunk_size / rerank are code defaults, not init choices. + + Interactive: questionary select/checkbox menus + y/N, pre-filled with the + project's current choices on re-init. Non-interactive (no TTY): drive from + ``--include`` / ``--exclude`` / ``--episodic`` flags — the automation/test + path. """ from dsagt.commands.setup_core_kb import COLLECTIONS, resolve_assets from dsagt.skills import KNOWN_SOURCES @@ -216,23 +201,14 @@ def _collect_settings(args, interactive: bool, existing: dict, pdir: Path | None [(s, s, s in cur_srcs) for s in skill_choices], ) - # Episodic memory (opt-in): captures session facts into session_memory. + # Episodic memory (opt-in): captures session turns into session_memory. cur_epi = existing.get("episodic", {}) or {} enable_epi = _confirm( - "Enable episodic memory? (distills session facts with a local LLM; " - "downloads ~1GB on first use)", + "Enable episodic memory? (captures session turns into searchable " + "memory)", default=bool(cur_epi.get("enabled")), ) - domain_tags: dict[str, str] = {} - if enable_epi: - cur_tags = cur_epi.get("domain_tags") or {} - domain_tags = _parse_domain_tags( - _prompt( - "Domain-specific memory tags, comma-separated (optional)", - default=", ".join(cur_tags), - ) - ) - episodic = _episodic_block(enable_epi, domain_tags) + episodic = _episodic_block(enable_epi) else: agent = args.agent or existing.get("agent") if not agent: @@ -244,15 +220,12 @@ def _collect_settings(args, interactive: bool, existing: dict, pdir: Path | None # Episodic is flag-driven here (automation); omit --episodic to leave it # off. Re-pass it on re-init — like --include/--exclude, flags are # authoritative on the non-interactive path. - episodic = _episodic_block( - getattr(args, "episodic", False), - _parse_domain_tags(getattr(args, "domain_tags", "") or ""), - ) + episodic = _episodic_block(getattr(args, "episodic", False)) return { "agent": agent, # The bundled ``tools`` collection is always provisioned. - "assets": ["tools", *collections, *skill_names], + "assets": ["codes", *collections, *skill_names], "knowledge": {"collections": collections}, "skills": _skills_block_for(skill_names), "episodic": episodic, @@ -272,7 +245,7 @@ def _handle_destructive( """ from dsagt.commands.setup_core_kb import asset_collection_name - protected = {"tool_use", "session_memory"} + protected = {"code_use", "session_memory"} # Agent switch → old platform's files are now stale. old_agent = existing.get("agent") @@ -702,15 +675,8 @@ def main(argv=None): p_init.add_argument( "--episodic", action="store_true", - help="Enable episodic memory (Tier-1 local-LLM distillation; downloads " - "~1GB on first use). Off by default.", - ) - p_init.add_argument( - "--domain-tags", - metavar="TAGS", - default="", - help="Comma-separated project-specific memory tags, merged onto the " - "stock taxonomy (only with --episodic).", + help="Enable episodic memory (captures session turns into searchable " + "memory). Off by default.", ) p_start = sub.add_parser("start", help="Start a project session") diff --git a/src/dsagt/commands/info.py b/src/dsagt/commands/info.py index ba3721d..4eea9d5 100644 --- a/src/dsagt/commands/info.py +++ b/src/dsagt/commands/info.py @@ -11,16 +11,16 @@ Aggregation reads ``trace_metadata`` for token totals + session id (MLflow stamps per-trace token usage as a JSON blob under -``mlflow.trace.tokenUsage``; the OTLP receiver promotes our -``session.id`` span attribute to ``mlflow.trace.session``). - -Source bucketing comes from the OTel ``service.name`` resource attribute -on each trace's root span (set per emitting process by ``init_tracing`` -and the agent's own OTel SDK). Possible values: - - ``claude-code`` / ``goose`` / ``cline`` / ``codex`` — - agent-emitted LLM-call traces (the bulk of traffic) - - ``dsagt-server`` — merged MCP server spans (``kb.*``, ``registry.*``) - - ``dsagt-run`` — tool-execute spans +``mlflow.trace.tokenUsage``; the live tracer stamps ``mlflow.trace.session``). + +Source bucketing reads the metadata DSAGT itself stamps on each trace — no +span inspection: + - ``memory`` / ``skill`` / ``knowledge`` / ``registry`` — internal debug + traces, from the ``dsagt.source`` tag (the MCP tool category the agent + invoked, set on the trace root by the dispatch shell). + - ``execution`` — dsagt-run tool-execute traces (``dsagt.source``). + - ``claude`` / ``goose`` / ``cline`` / ``codex`` — agent traces, from the + ``dsagt.agent`` metadata stamped by ``MLflowSink`` (the bulk of traffic). """ from __future__ import annotations @@ -225,102 +225,23 @@ def _fmt_count(n: int) -> str: return f"{n / 1_000_000:.1f}M" -#: Map root-span name prefix → human-readable source bucket. Claude's -#: native OTel emission uses ``claude_code.*``; goose uses ``goose.*`` / -#: ``dispatch_tool_call``; our internal services use the prefixes their -#: ``init_tracing`` calls assign. Used as the canonical bucket because -#: MLflow's OTLP receiver doesn't surface the ``service.name`` resource -#: attribute on the span data ``mlflow.search_traces`` returns — the -#: name-prefix mapping is reliable across MLflow versions and gives the -#: same answer (which emitting process produced this trace). -_SPAN_NAME_TO_SOURCE: tuple[tuple[str, str], ...] = ( - ("claude_code.", "claude"), - ("goose.", "goose"), - # Goose's Rust runtime emits root spans named after agent/provider - # operations (no namespace prefix): ``reply`` (agent turn), - # ``complete_with_model`` (provider LLM call), ``dispatch_tool_call`` - # (tool invocation). Verified against goose 1.7+ spans in - # crates/goose/src/agents/agent.rs and providers/openai.rs. - ("dispatch_tool_call", "goose"), - ("complete_with_model", "goose"), - ("reply", "goose"), - ("kb.", "dsagt-server"), - ("registry.", "dsagt-server"), - ("tool.execute", "dsagt-run"), -) - - -def _bucket_from_span_name(name: str | None) -> str | None: - if not name: - return None - for prefix, bucket in _SPAN_NAME_TO_SOURCE: - if name.startswith(prefix): - return bucket - return None - - -def _source_from_spans(spans) -> str | None: - """Bucket a trace by who emitted it. - - Tries ``service.name`` on span attributes / resource first (works for - dsagt-internal services that stamp it explicitly), then falls back to - mapping the root span's NAME prefix to a source bucket — necessary for - native claude / goose OTel because MLflow's OTLP receiver drops the - ``service.name`` resource attribute in the data ``mlflow.search_traces`` - exposes. +def _row_source_for(tags: dict, metadata: dict) -> str: + """Bucket a trace by who emitted it, from the metadata DSAGT itself stamps. - ``mlflow.search_traces`` returns a ``spans`` column whose entries - vary in shape across MLflow versions (Span object, dict, or pandas - Series of dicts); we defend against both attribute and item access. + No span inspection: internal debug traces carry an explicit ``dsagt.source`` + tag (the MCP tool category — ``memory`` / ``skill`` / ``knowledge`` / + ``registry`` — or ``execution`` for dsagt-run), set on the trace root by the + MCP dispatch shell / ``code_execute_span``. Agent traces carry ``dsagt.agent`` + metadata, stamped by ``MLflowSink``. Neither overlaps, so the bucket is a + direct lookup; anything else (a stray trace with neither) is ``"unknown"``. """ - if spans is None: - return None - try: - for span in spans: - attrs = getattr(span, "attributes", None) - if attrs is None and isinstance(span, dict): - attrs = span.get("attributes") - if attrs and "service.name" in attrs: - return attrs["service.name"] - resource = getattr(span, "resource", None) or ( - span.get("resource") if isinstance(span, dict) else None - ) - if resource: - rattrs = getattr(resource, "attributes", None) or ( - resource.get("attributes") if isinstance(resource, dict) else None - ) - if rattrs and "service.name" in rattrs: - return rattrs["service.name"] - # No service.name anywhere — fall back to span-name-prefix - # bucketing on the root span (the one without a parent) first. - for span in spans: - parent = ( - getattr(span, "parent_id", None) - or (span.get("parent_id") if isinstance(span, dict) else None) - or getattr(span, "parent_span_id", None) - or (span.get("parent_span_id") if isinstance(span, dict) else None) - ) - if parent: - continue - name = getattr(span, "name", None) or ( - span.get("name") if isinstance(span, dict) else None - ) - bucket = _bucket_from_span_name(name) - if bucket: - return bucket - # No root present (rare — some emitters produce spans whose - # parent_span_id points outside their own trace). Try any span - # whose name we recognize. - for span in spans: - name = getattr(span, "name", None) or ( - span.get("name") if isinstance(span, dict) else None - ) - bucket = _bucket_from_span_name(name) - if bucket: - return bucket - except (TypeError, AttributeError): - return None - return None + src = (tags or {}).get("dsagt.source") + if src: + return src + agent = (metadata or {}).get("dsagt.agent") + if agent and agent != "-": + return agent + return "unknown" def _is_error(state) -> bool: @@ -510,21 +431,22 @@ def _report(project_name: str, config: dict, traces) -> dict: # column once up front keeps pandas from re-parsing the dict on every # groupby. md = traces["trace_metadata"].apply(lambda m: m or {}) + tags = ( + traces["tags"].apply(lambda t: t or {}) + if "tags" in traces.columns + else md.apply(lambda _: {}) + ) session = md.apply(lambda m: m.get("mlflow.trace.session") or "(no-session)") agent = md.apply(lambda m: m.get("dsagt.agent") or "-") errored = traces["state"].apply(_is_error) - # Source + tokens both walk the spans column. Fall back to span data - # because MLflow's OTLP receiver doesn't surface ``service.name`` (so - # metadata-only bucketing returns "unknown" for everything) and not all - # traces carry ``mlflow.trace.tokenUsage`` (so token totals would be - # zero without this fallback). + # Source comes from the metadata DSAGT stamps (dsagt.source tag / + # dsagt.agent), no span inspection. Tokens still walk the spans column as + # a fallback because not all traces carry ``mlflow.trace.tokenUsage``. spans_col = traces["spans"] if "spans" in traces.columns else None def _row_source(idx: int) -> str: - if spans_col is not None: - return _source_from_spans(spans_col.iloc[idx]) or "unknown" - return "unknown" + return _row_source_for(tags.iloc[idx], md.iloc[idx]) def _row_tokens(idx: int) -> tuple[int, int]: # Trace metadata first (when tokenUsage is present), then aggregate diff --git a/src/dsagt/commands/run_tool.py b/src/dsagt/commands/run_code.py similarity index 87% rename from src/dsagt/commands/run_tool.py rename to src/dsagt/commands/run_code.py index 233ad78..757134f 100644 --- a/src/dsagt/commands/run_tool.py +++ b/src/dsagt/commands/run_code.py @@ -1,8 +1,8 @@ """ -dsagt-run: Tool execution wrapper for provenance capture. +dsagt-run: registered-code execution wrapper for provenance capture. Usage: - dsagt-run --tool fastp -- fastp -q 20 -l 50 --in1 reads.fq.gz + dsagt-run --code fastp -- fastp -q 20 -l 50 --in1 reads.fq.gz """ import argparse @@ -14,10 +14,10 @@ def _make_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="dsagt-run", - description="Wrap a tool command and capture execution provenance.", + description="Wrap a code command and capture execution provenance.", ) parser.add_argument( - "--tool", required=True, help="Name of the tool being executed." + "--code", required=True, help="Name of the code being executed." ) parser.add_argument( "--session", @@ -68,7 +68,7 @@ def main(argv: list[str] | None = None) -> int: records_dir = _resolve_records_dir(args.records_dir) return run_and_record( - tool_name=args.tool, + code_name=args.code, command=command, records_dir=records_dir, session_id=args.session, diff --git a/src/dsagt/commands/setup_core_kb.py b/src/dsagt/commands/setup_core_kb.py index b8c433c..e223566 100644 --- a/src/dsagt/commands/setup_core_kb.py +++ b/src/dsagt/commands/setup_core_kb.py @@ -12,7 +12,7 @@ :data:`DEFAULT_ASSETS` (bundled tools + the genesis skill catalog) is the cheap set installed automatically on a machine's first project. Embedding -config comes from the project's ``dsagt_config.yaml`` (local backend by +config comes from the project's ``.dsagt/config.yaml`` (local backend by default — no credentials needed). """ @@ -286,7 +286,7 @@ def _current_dsagt_version() -> str: # # Three kinds, all built into the shared ``~/dsagt-projects/kb_index/`` and # copied per-project: -# "tools" bundled tool specs (package data; cheap, fully local) +# "codes" bundled tool specs (package data; cheap, fully local) # a skill-catalog source from ``skills.KNOWN_SOURCES`` # (e.g. "genesis", "k-dense-ai", "composio", "antigravity") # a heavy scientific doc collection from ``COLLECTIONS`` @@ -299,23 +299,23 @@ def _current_dsagt_version() -> str: #: The default per-project / first-init asset set: bundled tools + the #: genesis skill catalog. Kept deliberately cheap (one small local embed + #: one git clone) so onboarding needs no manual step. -DEFAULT_ASSETS: tuple[str, ...] = ("tools", "genesis") +DEFAULT_ASSETS: tuple[str, ...] = ("codes", "genesis") def all_assets() -> list[str]: """Every installable asset name, in canonical install order (cheap → heavy).""" from dsagt.skills import KNOWN_SOURCES - return ["tools", *KNOWN_SOURCES, *COLLECTIONS] + return ["codes", *KNOWN_SOURCES, *COLLECTIONS] def asset_collection_name(asset: str) -> str: """The ``kb_index`` collection directory a given asset materializes as.""" - from dsagt.registry import TOOLS_COLLECTION, CATALOG_COLLECTION_PREFIX + from dsagt.registry import CODES_COLLECTION, CATALOG_COLLECTION_PREFIX from dsagt.skills import KNOWN_SOURCES, _repo_slug - if asset == "tools": - return TOOLS_COLLECTION + if asset == "codes": + return CODES_COLLECTION if asset in KNOWN_SOURCES: return CATALOG_COLLECTION_PREFIX + _repo_slug(KNOWN_SOURCES[asset]["url"]) if asset in COLLECTIONS: @@ -387,38 +387,38 @@ def _build_bundled_tools(kb, index_dir: Path) -> int: Each spec file is one chunk with rich metadata. Wipe-and-rebuild so a dsagt upgrade refreshes the bundled set. Returns the number indexed. """ - from dsagt.registry import TOOLS_COLLECTION, ToolRegistry, _parse_frontmatter + from dsagt.registry import CODES_COLLECTION, CodeRegistry, _parse_frontmatter - coll_dir = index_dir / TOOLS_COLLECTION + coll_dir = index_dir / CODES_COLLECTION if coll_dir.exists(): shutil.rmtree(coll_dir) - tool_paths = [ + code_paths = [ p - for p in sorted(ToolRegistry._PACKAGE_TOOLS_DIR.glob("*.md")) + for p in sorted(CodeRegistry._PACKAGE_CODES_DIR.glob("*.md")) if _parse_frontmatter(p).get("name") ] - if not tool_paths: + if not code_paths: return 0 current_version = _current_dsagt_version() - tool_specs = [_parse_frontmatter(p) for p in tool_paths] + code_specs = [_parse_frontmatter(p) for p in code_paths] kb.add_entries( - texts=[p.read_text() for p in tool_paths], - collection=TOOLS_COLLECTION, + texts=[p.read_text() for p in code_paths], + collection=CODES_COLLECTION, metadatas=[ { - "tool_name": s["name"], + "code_name": s["name"], "tags": ",".join(s.get("tags", [])), "executable": s.get("executable", ""), "has_dependencies": str(bool(s.get("dependencies"))), "source": "bundled", "dsagt_version": current_version, } - for s in tool_specs + for s in code_specs ], ) - return len(tool_paths) + return len(code_paths) def ensure_assets( @@ -487,7 +487,7 @@ def ensure_assets( print(" Downloading embedding model (one-time) …", flush=True) for asset in to_build: - if asset == "tools": + if asset == "codes": print(" Indexing bundled tools …", flush=True) _build_bundled_tools(kb, index_dir) built.append(asset) diff --git a/src/dsagt/dsagt_instructions.md b/src/dsagt/dsagt_instructions.md index cebfcdd..4c81816 100644 --- a/src/dsagt/dsagt_instructions.md +++ b/src/dsagt/dsagt_instructions.md @@ -1,29 +1,29 @@ # DSAgt Pipeline Builder -You are an agentic data pipeline builder. You help domain scientists create **reproducible, auditable data curation pipelines** through iterative, knowledge-driven tool generation. +You are an agentic data pipeline builder. You help domain scientists create **reproducible, auditable data curation pipelines** through iterative, knowledge-driven code generation. ## CRITICAL CONSTRAINTS -### 1. Tool-Mediated Data Access +### 1. Code-Mediated Data Access **Never directly access, observe, assess, transform, or manipulate data.** This includes listing data directories, scanning files, and previewing samples — even when a built-in `shell` or `text_editor` tool would technically work. -All data operations must be performed by calling registered tools. The point is the execution record in `trace_archive/`, not just the result: a built-in shell or editor call leaves no record and breaks pipeline reconstruction. If a needed capability doesn't exist, generate and register it first, then call it. +All data operations must be performed by calling registered codes. The point is the execution record in `trace_archive/`, not just the result: a built-in shell or editor call leaves no record and breaks pipeline reconstruction. If a needed capability doesn't exist, generate and register it first, then call it. ### 1a. Memory: kb_remember / kb_get_memories Are Mandatory **Whenever the user says "remember", "note that", "keep in mind", "for future reference", or otherwise asks you to retain a fact, you MUST call `kb_remember(text=...)` in the same turn.** Mentioning the fact in your response or claiming you have "stored" or "noted" it without making the tool call is a hallucination — the fact is not persisted and a future session will not see it. End-of-session episodic extraction is automatic and unrelated; it is NOT a substitute for explicit memory. **Whenever the user says "what do you remember", "recall", or asks you to retrieve a previously-stored fact, you MUST call `kb_get_memories()` first** and answer based on its result, not from in-context message history. -### 1b. Registered-Tool Invocation: Use the `executable` String Verbatim -**When invoking a registered tool, copy the spec's `executable` field byte-for-byte, including any `dsagt-run --tool --` prefix.** The prefix is the wrapper that writes the execution record to `trace_archive/`; bypassing it (e.g. running `python tools/scan_directory.py` directly when the spec says `dsagt-run --tool scan_directory -- python tools/scan_directory.py`) loses provenance and breaks pipeline reconstruction. If `dsagt-run` errors with "command not found", surface the error rather than working around it. +### 1b. Registered-Code Invocation: Use the `executable` String Verbatim +**When invoking a registered code, copy the spec's `executable` field byte-for-byte, including any `dsagt-run --code --` prefix.** The prefix is the wrapper that writes the execution record to `trace_archive/`; bypassing it (e.g. running `python codes/scan_directory.py` directly when the spec says `dsagt-run --code scan_directory -- python codes/scan_directory.py`) loses provenance and breaks pipeline reconstruction. If `dsagt-run` errors with "command not found", surface the error rather than working around it. -### 2. Tool and Skill Discovery +### 2. Code and Skill Discovery Before implementing anything, search for existing capabilities: -- `search_registry(query)` — find registered CLI tools by name, tag, or description (semantic search) +- `search_registry(query)` — find registered CLI codes by name, tag, or description (semantic search) - `search_skills(query)` — find agent skills (workflows, templates, procedures) -- `get_registry()` — list all registered tools +- `get_registry()` — list all registered codes **Skills come in two tiers.** *Installed* skills (in this project) are discovered **natively** by your platform — their names/descriptions are already in your context and you auto-invoke them; you do NOT need `search_skills` to find those. Separately there is a much larger *external catalog* of installable skills (entries marked `[catalog]`), NOT loaded into context. @@ -36,7 +36,7 @@ Before implementing anything, search for existing capabilities: To author a brand-new skill instead of installing one, use the bundled `skill-creator` skill. -**When the user indicates they want a specific tool used** — phrasings like "use tool `foo`", "use `foo` from the registry", "run `foo`", or similar — look it up first (`search_registry(tool_name=...)` for exact match, `get_registry()` to browse). Read the returned spec's `executable` field and each parameter's `cli` field, then invoke via your shell. Do not substitute your own file/shell tools for a task a registered tool can do. (See section 1b for the verbatim-`executable` rule.) +**When the user indicates they want a specific code used** — phrasings like "use `foo`", "use `foo` from the registry", "run `foo`", or similar — look it up first (`search_registry(code_name=...)` for exact match, `get_registry()` to browse). Read the returned spec's `executable` field and each parameter's `cli` field, then invoke via your shell. Do not substitute your own file/shell tools for a task a registered code can do. (See section 1b for the verbatim-`executable` rule.) **Rendering parameters**: each parameter's `cli` field pins exactly how its value goes on the command line. Emit positional args first (in position order), then named args. Skip optional parameters whose value is absent; use the `default` when present. @@ -52,20 +52,20 @@ To author a brand-new skill instead of installing one, use the bundled `skill-cr Booleans render as a bare flag when truthy, nothing when falsy. -When registering a new tool via `save_tool_spec`, set the `cli` field on every parameter so the next invocation doesn't have to guess. +When registering a new code via `save_code_spec`, set the `cli` field on every parameter so the next invocation doesn't have to guess. -### 3. Tool Preference Hierarchy +### 3. Code Preference Hierarchy When implementing any data operation, follow this hierarchy: -1. **REGISTERED TOOL** — Use an existing tool (`search_registry`) -2. **KB PACKAGE TOOL** — Create a tool leveraging a package documented in the KB -3. **CUSTOM IMPLEMENTATION** — Write custom code to `tools/code/` and register it +1. **REGISTERED CODE** — Use an existing code (`search_registry`) +2. **KB PACKAGE CODE** — Create a code leveraging a package documented in the KB +3. **CUSTOM IMPLEMENTATION** — Write custom code to `codes/scripts/` and register it Always exhaust higher-preference options before falling to lower ones. ### 4. Per-Operation Checks -Every filter/transform has an associated check tool. Run it before AND after: +Every filter/transform has an associated check code. Run it before AND after: ``` check_[X](input) → audit/step_N_pre.json operation(input, output) @@ -75,7 +75,7 @@ check_[X](output) → audit/step_N_post.json All check reports are saved to `audit/` for the audit trail. ### 5. File Organization -- All processing code goes in `tools/code/` +- All processing code goes in `codes/scripts/` - All data output goes in a `data/` subdirectory - All audit reports go in `audit/` - All session artifacts stay within the project directory @@ -108,17 +108,17 @@ If yes: use `kb_ingest` to index them. Review what's available: `kb_list_collections()` -### 3. Register User's Custom Tools +### 3. Register User's Custom Codes -Ask: "Do you have existing scripts or tools you'd like to incorporate?" +Ask: "Do you have existing scripts or codes you'd like to incorporate?" -If yes, register them using `save_tool_spec`. +If yes, register them using `save_code_spec`. ### 4. Explore Available Resources Survey what's available before proceeding: -- `get_registry()` — list all tools -- `search_registry(query)` — semantic search for tools +- `get_registry()` — list all codes +- `search_registry(query)` — semantic search for codes - `search_skills(query)` — find available skills - `kb_list_collections()` — list knowledge base collections @@ -127,11 +127,11 @@ Survey what's available before proceeding: For **each data manipulation step**, cycle through: 1. **UNDERSTAND** what needs to happen at this step -2. **EXPLORE** knowledge base (`kb_search`) and tools (`search_registry`) -3. **APPLY** tool preference hierarchy +2. **EXPLORE** knowledge base (`kb_search`) and codes (`search_registry`) +3. **APPLY** code preference hierarchy 4. **DESIGN** the check and operation (confirm with user) -5. **GENERATE** code for check tool AND operation tool -6. **REGISTER** new tools via `save_tool_spec` +5. **GENERATE** the check code AND operation code +6. **REGISTER** new codes via `save_code_spec` 7. **EXECUTE** with before/after checks 8. **EVALUATE** results with user @@ -143,19 +143,19 @@ The knowledge base contains domain documentation, package references, implementa - `kb_search(query, collection, top_k)` — semantic search - `kb_ingest(folder_path)` — index new documents -## TOOL GENERATION PATTERN +## CODE GENERATION PATTERN -For each data operation, create TWO tools: +For each data operation, create TWO codes: -**Check Tool** — Quantifies the relevant metric +**Check Code** — Quantifies the relevant metric - Accepts: input data path, output report path, threshold parameters - Outputs: JSON report with counts, rates, distributions -**Operation Tool** — Performs the filter/transform +**Operation Code** — Performs the filter/transform - Accepts: input data path, output data path, parameters - Outputs: Transformed data -Write tool scripts to `tools/code/` and register them via `save_tool_spec`. Python dependencies declared in the spec are handled automatically via `uv run --with`. +Write code scripts to `codes/scripts/` and register them via `save_code_spec`. Python dependencies declared in the spec are handled automatically via `uv run --with`. ## PIPELINE RECONSTRUCTION @@ -165,12 +165,12 @@ At any point, you can reconstruct the pipeline from execution records: ## PRINCIPLES -1. **Setup first** — Extend KB and register user tools before iterating -2. **Follow the hierarchy** — Registered tool → KB package tool → Custom implementation +1. **Setup first** — Extend KB and register user codes before iterating +2. **Follow the hierarchy** — Registered code → KB package code → Custom implementation 3. **Explore first** — Search registry and KB before writing new code 4. **Iterate** — One manipulation step at a time; evaluate before proceeding -5. **Generate paired tools** — Both check and operation for each step +5. **Generate paired codes** — Both check and operation for each step 6. **Register everything** — Registry captures the complete pipeline 7. **Audit everything** — Before/after reports for every operation 8. **Confirm with user** — Domain scientist validates approach at each step -9. **No direct data access** — All operations through registered tools +9. **No direct data access** — All operations through registered codes diff --git a/src/dsagt/judge.py b/src/dsagt/judge.py deleted file mode 100644 index 4660769..0000000 --- a/src/dsagt/judge.py +++ /dev/null @@ -1,310 +0,0 @@ -""" -The episodic-memory Judge — distills one conversational turn into a few tagged, -≤1-sentence facts for the ``session_memory`` collection. - -This is Phase-3's Tier-1 layer (see ``design-notes/memory-plan.md``). A -:class:`Judge` is the small generative LLM that turns a turn's exchanges into -``[{text, tag}]`` facts: closed-set tag classification over the project's tag -taxonomy + a ≤1-sentence *extractive* condense, with an explicit empty escape -(most turns produce nothing). Reliability comes from asking it to do little — -classify into a fixed set and copy out one sentence, never "invent a category" -or summarize — which is exactly what a small (1–3B-class) model does well, and a -GBNF grammar pins the output to the JSON-array-of-tagged-facts shape so even a -1.5B model cannot emit malformed JSON or an off-taxonomy tag. - -The abstraction mirrors :class:`dsagt.knowledge.Embedder`: ``Judge.create`` -selects a backend; :class:`LocalJudge` is the default (an embeddable GGUF via -``llama-cpp-python`` — *local-by-default* so no second API key/cost gates -adoption), :class:`APIJudge` keeps the OpenAI-/Anthropic-compatible door open. -This module imports nothing heavy at import time; the GGUF runtime and the HTTP -client are lazy-loaded at first use so importing :mod:`dsagt.judge` (and merely -constructing a judge) stays cheap. - -:class:`LocalJudge` is **live** (grammar-constrained GGUF inference); -:class:`APIJudge`'s ``distill`` is still a no-op (returns ``[]``) pending demand -— the abstraction, factory, prompt builder, and parser are shared, so filling it -in is a fill-in, not a refactor. - -Class map — ``▷`` inherits · ``◇`` holds:: - - Judge (ABC) backend selector + distill() contract - ├─▷ LocalJudge grammar-constrained GGUF (llama-cpp-python) - └─▷ APIJudge OpenAI/Anthropic-compatible endpoint (stub) - - build_distill_prompt(exchanges, tags) → str the lean per-turn prompt - parse_distill_response(text, tags) → [{text, tag}] tolerant JSON parse -""" - -from __future__ import annotations - -import json -import logging -from abc import ABC, abstractmethod - -logger = logging.getLogger(__name__) - - -# --------------------------------------------------------------------------- -# Prompt construction + response parsing (pure — shared by every backend) -# --------------------------------------------------------------------------- - - -def _render_turn(exchanges: list[dict]) -> str: - """Flatten a turn's exchanges into a compact ``[role] text`` transcript. - - Reuses the content-block shape :meth:`CanonicalTrace.to_exchanges` emits - (``new_messages`` / ``response`` lists of Anthropic blocks). Kept local to - the judge rather than importing memory's richer renderer: the judge wants a - terse view (it classifies, it doesn't audit), and the seam stays one-way. - """ - lines: list[str] = [] - for ex in exchanges: - for msg in ex.get("new_messages", []): - role = msg.get("role", "user") - for block in msg.get("content", []): - if block.get("type") == "text" and block.get("text"): - lines.append(f"[{role}] {block['text']}") - elif block.get("type") == "tool_use": - lines.append(f"[{role} → {block.get('name', 'tool')}]") - for block in ex.get("response", []): - if block.get("type") == "text" and block.get("text"): - lines.append(f"[assistant] {block['text']}") - elif block.get("type") == "tool_use": - lines.append(f"[assistant → {block.get('name', 'tool')}]") - return "\n".join(lines) - - -def build_distill_prompt(exchanges: list[dict], tags: dict[str, str]) -> str: - """The lean per-turn distillation prompt. - - Deliberately *not* ``memory.build_extraction_prompt`` (facts + summary + - insights + classify is too much for a small model). One job: pick a tag - from the closed set and copy out ≤1 declarative sentence per fact, or return - ``[]`` when the turn carries nothing worth remembering. - """ - tag_list = "\n".join(f"- {name}: {desc}" for name, desc in tags.items()) - transcript = _render_turn(exchanges) - return f"""\ -You extract durable facts from one turn of a data-pipeline assistant session. - -Rules: -- Output ONLY a JSON array, no prose, no markdown fences. -- Each element is {{"fact": "", "tag": ""}}. -- Copy facts out of the turn (extract); do not summarize or infer. -- "tag" MUST be exactly one of the tags below — never invent one. -- Most turns carry nothing durable. When in doubt, output []. - -Tags: -{tag_list} - -Turn: -{transcript} - -JSON array:""" - - -def parse_distill_response(text: str, tags: dict[str, str]) -> list[dict]: - """Parse the model's JSON array into validated ``[{text, tag}]`` facts. - - Tolerant of a markdown fence around the array. Drops malformed elements - and any fact whose tag is outside the taxonomy (the closed set is what makes - the small model viable — an off-set tag is a model error, not a new tag). - """ - cleaned = text.strip() - if cleaned.startswith("```"): - cleaned = cleaned.split("\n", 1)[1] if "\n" in cleaned else cleaned[3:] - if cleaned.endswith("```"): - cleaned = cleaned[:-3] - cleaned = cleaned.strip() - if not cleaned: - return [] - - parsed = json.loads(cleaned) - if not isinstance(parsed, list): - return [] - - facts: list[dict] = [] - for item in parsed: - if not isinstance(item, dict): - continue - fact = (item.get("fact") or "").strip() - tag = (item.get("tag") or "").strip() - if fact and tag in tags: - facts.append({"text": fact, "tag": tag}) - return facts - - -# --------------------------------------------------------------------------- -# Judge abstraction -# --------------------------------------------------------------------------- - - -class Judge(ABC): - """A small generative LLM that distills a turn into tagged facts. - - Stateless after construction (the model/connection is the only state). A - single judge instance is shared by one :class:`~dsagt.memory.MemoryExtractor` - across every turn it processes. - """ - - #: Short backend tag for span labelling / config display. - backend: str = "unknown" - #: Model identifier; subclasses set this in ``__init__``. - model: str | None = None - - @classmethod - def create( - cls, - backend: str, - *, - model: str | None = None, - base_url: str | None = None, - api_key: str | None = None, - provider: str | None = None, - ) -> "Judge": - """Factory: construct the one judge for an extractor, with explicit args. - - Mirrors :meth:`Embedder.create` — each backend pulls only the parameters - it uses, no ``**kwargs`` splat. Defaults to ``local`` (the adoption - bet: no API key required). - """ - backend = (backend or "local").lower() - if backend == "local": - return LocalJudge(model=model) - if backend == "api": - return APIJudge( - model=model, base_url=base_url, api_key=api_key, provider=provider - ) - raise ValueError(f"Unknown judge backend {backend!r}. Choose from: local, api") - - @abstractmethod - def distill(self, exchanges: list[dict], tags: dict[str, str]) -> list[dict]: - """Distill one turn's ``exchanges`` into ``[{text, tag}]`` facts (or ``[]``).""" - - def close(self) -> None: - pass - - -class LocalJudge(Judge): - """Embeddable GGUF inference via ``llama-cpp-python`` — runs fully offline. - - The local weight is real (a generative LLM, GBs on disk + an inference - runtime), unlike the light ``LocalEmbedder``; it loads lazily on first - ``distill`` so constructing a ``LocalJudge`` (e.g. when memory is wired but - Tier-1 stays disabled) costs nothing. - """ - - backend = "local" - - #: A small instruction-tuned, quantized GGUF — the class the plan calls for - #: (reliable at closed-set classification, cheap to run on CPU; ~1GB Q4). - #: GBNF grammar guarantees valid JSON regardless of size, so 1.5B is a safe - #: quality floor for the one-sentence condense rather than a capability bet. - #: Pulled from the HF hub on first use and cached; override via the - #: ``judge.model`` config (e.g. a 0.5B for speed, or a local .gguf path). - DEFAULT_REPO = "Qwen/Qwen2.5-1.5B-Instruct-GGUF" - DEFAULT_FILE = "qwen2.5-1.5b-instruct-q4_k_m.gguf" - - def __init__(self, model: str | None = None): - # ``model``, when set, is a local .gguf path; otherwise the default HF - # repo/file pair is fetched lazily. Stored only — no I/O here. - self.model = model or self.DEFAULT_REPO - self._model_path = model - self._llm = None # lazily-loaded llama_cpp.Llama - self._grammars: dict[tuple, object] = {} # tag-set → compiled LlamaGrammar - - def _ensure_llm(self): - """Load the GGUF runtime on first use (heavy: native lib + GB weights).""" - if self._llm is not None: - return self._llm - try: - from llama_cpp import Llama - except ImportError as e: # actionable — it's a core dep, so this is an - # incomplete install, not a missing opt-in. - raise RuntimeError( - "LocalJudge needs llama-cpp-python (a core dependency) — " - "reinstall with `uv sync`." - ) from e - if self._model_path: - self._llm = Llama(model_path=self._model_path, n_ctx=8192, verbose=False) - else: - self._llm = Llama.from_pretrained( - repo_id=self.DEFAULT_REPO, - filename=self.DEFAULT_FILE, - n_ctx=8192, - verbose=False, - ) - return self._llm - - def _grammar(self, tags: dict[str, str]): - """A GBNF grammar pinning output to ``[{fact: str, tag: }]``. - - Compiled from a JSON schema whose ``tag`` is an enum of the taxonomy, so - the model *cannot* emit malformed JSON or an off-set tag — the single - biggest reliability lever for a small model (``parse_distill_response`` - is then just defence in depth). Cached per tag-set since the taxonomy is - fixed for a project. - """ - key = tuple(sorted(tags)) - if key not in self._grammars: - from llama_cpp import LlamaGrammar - - schema = { - "type": "array", - "items": { - "type": "object", - "properties": { - "fact": {"type": "string"}, - "tag": {"type": "string", "enum": list(tags)}, - }, - "required": ["fact", "tag"], - "additionalProperties": False, - }, - } - self._grammars[key] = LlamaGrammar.from_json_schema(json.dumps(schema)) - return self._grammars[key] - - def distill(self, exchanges: list[dict], tags: dict[str, str]) -> list[dict]: - if not exchanges or not tags: - return [] - llm = self._ensure_llm() - out = llm.create_chat_completion( - messages=[ - {"role": "user", "content": build_distill_prompt(exchanges, tags)} - ], - max_tokens=512, - temperature=0.0, # deterministic extraction, not creative writing - grammar=self._grammar(tags), - ) - return parse_distill_response(out["choices"][0]["message"]["content"], tags) - - -class APIJudge(Judge): - """OpenAI-/Anthropic-compatible endpoint — keeps the remote door open. - - Reuses the project's LLM gateway settings; carries a credential, so it is - never the default (see the plan's adoption argument for local-by-default). - """ - - backend = "api" - - DEFAULT_MODEL = "claude-haiku-4-5-20251001" - - def __init__( - self, - model: str | None = None, - base_url: str | None = None, - api_key: str | None = None, - provider: str | None = None, - ): - self.model = model or self.DEFAULT_MODEL - self._base_url = base_url - self._api_key = api_key - self._provider = provider - - def distill(self, exchanges: list[dict], tags: dict[str, str]) -> list[dict]: - # Tier-1 not yet wired. When it is, mirror the old - # ``memory.call_extraction_llm`` shape (litellm completion against the - # configured gateway), then ``parse_distill_response`` the result. - del exchanges, tags - return [] diff --git a/src/dsagt/knowledge.py b/src/dsagt/knowledge.py index 5313069..ab3204b 100644 --- a/src/dsagt/knowledge.py +++ b/src/dsagt/knowledge.py @@ -206,7 +206,7 @@ class LocalEmbedder(Embedder): #: ``bge-base`` variant we used previously, with ~2 nDCG@10 points #: lower MTEB retrieval score — a hard-to-notice difference for #: typical DSAGT KB sizes (single-digit thousands of chunks). - #: Override via ``embedding.model`` in ``dsagt_config.yaml`` (e.g. + #: Override via ``embedding.model`` in ``.dsagt/config.yaml`` (e.g. #: ``BAAI/bge-large-en-v1.5`` for higher quality at the cost of #: ~10× memory and ~5× CPU). DEFAULT_MODEL = "BAAI/bge-small-en-v1.5" @@ -522,7 +522,12 @@ def __init__(self, collection_name: str, persist_dir: Path | None = None): # ChromaDB stores ids internally; we maintain a positional list so that # returned integer indices are consistent with the chunk list. - def add(self, embeddings: np.ndarray, metadatas: list[dict] | None = None) -> None: + def add( + self, + embeddings: np.ndarray, + metadatas: list[dict] | None = None, + documents: list[str] | None = None, + ) -> None: start = len(self._ids) new_ids = [str(start + i) for i in range(len(embeddings))] self._ids.extend(new_ids) @@ -540,10 +545,18 @@ def add(self, embeddings: np.ndarray, metadatas: list[dict] | None = None) -> No } if metadatas is not None: kwargs["metadatas"] = metadatas[i : i + batch_size] + # Store the chunk text as the Chroma *document* too — that's what + # ``where_document`` ($contains / $regex) matches against. + if documents is not None: + kwargs["documents"] = documents[i : i + batch_size] self._col.add(**kwargs) def search( - self, query_vec: np.ndarray, k: int, where: dict | None = None + self, + query_vec: np.ndarray, + k: int, + where: dict | None = None, + where_document: dict | None = None, ) -> tuple[np.ndarray, np.ndarray]: k = min(k, len(self._ids)) if k == 0: @@ -551,6 +564,8 @@ def search( query_kwargs: dict = {"query_embeddings": [query_vec.tolist()], "n_results": k} if where is not None: query_kwargs["where"] = where + if where_document is not None: + query_kwargs["where_document"] = where_document results = self._col.query(**query_kwargs) chroma_ids = results["ids"][0] distances = results["distances"][0] # cosine distance (0=identical) @@ -843,7 +858,12 @@ def add_entries( @abstractmethod def search( - self, query: str, collection: str, top_k: int, where: dict | None = None + self, + query: str, + collection: str, + top_k: int, + where: dict | None = None, + where_document: dict | None = None, ) -> list[dict]: """Single-collection hybrid search → ``[{chunk, score}, ...]``.""" @@ -985,7 +1005,7 @@ def add_chunks( existing_chunks = [] try: - index.add(embeddings, metadatas=metadatas) + index.add(embeddings, metadatas=metadatas, documents=texts) except Exception as e: hint = self._stale_index_message(collection, e) if hint: @@ -1043,14 +1063,20 @@ def add_entries( # -- single-collection hybrid search ------------------------------------ def search( - self, query: str, collection: str, top_k: int, where: dict | None = None + self, + query: str, + collection: str, + top_k: int, + where: dict | None = None, + where_document: dict | None = None, ) -> list[dict]: index, chunks = self._load(collection) query_emb = self.embed([query])[0] - # ``where`` (metadata filter) disables the BM25 leg — BM25 has no - # metadata-filter equivalent, so a filtered search is dense-only. - filtered = where is not None + # A ``where`` (metadata) or ``where_document`` (text content) filter + # disables the BM25 leg — BM25 has no filter equivalent, so a filtered + # search is dense-only. + filtered = where is not None or where_document is not None do_hybrid = not filtered # Oversample the per-ranker pools so RRF has depth on small top_k. candidate_k = min( @@ -1062,7 +1088,10 @@ def search( try: if filtered: dense_scores, dense_indices = index.search( - query_emb, candidate_k, where=where + query_emb, + candidate_k, + where=where, + where_document=where_document, ) else: dense_scores, dense_indices = index.search(query_emb, candidate_k) @@ -1472,6 +1501,7 @@ def search( top_k: int = 5, rerank: bool | None = None, where: dict | None = None, + where_document: dict | None = None, ) -> list[dict]: """Search one or many collections, fusing across them by rank. @@ -1507,7 +1537,15 @@ def search( for coll in targets: try: store = self._store_for(coll) - per_coll.append(store.search(query, coll, candidate_k, where=where)) + per_coll.append( + store.search( + query, + coll, + candidate_k, + where=where, + where_document=where_document, + ) + ) except (ValueError, FileNotFoundError, KeyError) as e: logger.warning("Search failed for '%s': %s", coll, e) errors.append(str(e)) diff --git a/src/dsagt/mcp/__init__.py b/src/dsagt/mcp/__init__.py index 8fcb345..2f8b1e6 100644 --- a/src/dsagt/mcp/__init__.py +++ b/src/dsagt/mcp/__init__.py @@ -4,7 +4,7 @@ * :mod:`dsagt.mcp.registry_tools` — tool registry + execution + provenance * :mod:`dsagt.mcp.knowledge_tools` — knowledge-base retrieval -* :mod:`dsagt.mcp.memory_tools` — explicit memory + suggestions +* :mod:`dsagt.mcp.memory_tools` — explicit memory * :mod:`dsagt.mcp.skill_tools` — skill search / install / sources :mod:`dsagt.mcp.server` composes all four under one ``Server("dsagt")`` and diff --git a/src/dsagt/mcp/knowledge_tools.py b/src/dsagt/mcp/knowledge_tools.py index d54895a..8b43b17 100644 --- a/src/dsagt/mcp/knowledge_tools.py +++ b/src/dsagt/mcp/knowledge_tools.py @@ -11,7 +11,7 @@ the KB's store list, not a tool the agent calls). Server configuration (chunk_size, rerank) is read from the project's -dsagt_config.yaml. Embedding credentials flow through env vars (EMBEDDING_API_KEY, +.dsagt/config.yaml. Embedding credentials flow through env vars (EMBEDDING_API_KEY, EMBEDDING_BASE_URL, EMBEDDING_MODEL) set by ``dsagt start``. These definitions + handlers run inside the merged ``dsagt-server`` (see @@ -131,7 +131,7 @@ async def _handle_kb_search( # would be invalid, so we only pass where when there are real filters. where = { key: arguments[key] - for key in ("category", "session_id", "source_type", "tool_name") + for key in ("category", "session_id", "source_type", "code_name", "tool_name") if arguments.get(key) is not None } return_code = arguments.get("return_code") @@ -140,6 +140,21 @@ async def _handle_kb_search( if len(where) > 1: where = {"$and": [{k: v} for k, v in where.items()]} + # Document-content filter (over the chunk text itself, complementary to the + # metadata ``where``). ``regex`` is the powerful leg — ChromaDB ``$regex``, + # with ``(?i)`` for case-insensitive; ``contains`` is a case-sensitive + # substring. Both narrow the candidate pool before vector ranking. + doc_filters = [] + if arguments.get("regex"): + doc_filters.append({"$regex": arguments["regex"]}) + if arguments.get("contains"): + doc_filters.append({"$contains": arguments["contains"]}) + where_document = ( + doc_filters[0] + if len(doc_filters) == 1 + else {"$and": doc_filters} if doc_filters else None + ) + # Fan-out + rank-fusion across collections lives in KnowledgeBase.search; # the tool just names collection(s). A single internal collection routes # straight to its store; multiple/external targets federate by RRF. @@ -152,6 +167,7 @@ async def _handle_kb_search( top_k=top_k, rerank=rerank, where=where or None, + where_document=where_document, ) except ValueError as e: return {"status": "error", "error": str(e)} @@ -336,7 +352,7 @@ def _knowledge_tools_and_handlers(kb: KnowledgeBase): Combined with the other concern modules' tools under one MCP ``Server`` by :func:`dsagt.mcp.server.create_dsagt_server`. The rerank default is on - ``kb.default_rerank`` (set from ``knowledge.rerank`` in dsagt_config.yaml). + ``kb.default_rerank`` (set from ``knowledge.rerank`` in .dsagt/config.yaml). """ job_tracker = _JobTracker() @@ -363,7 +379,10 @@ def _knowledge_tools_and_handlers(kb: KnowledgeBase): description=( "Search knowledge base collections using semantic similarity. " "Returns relevant chunks with source metadata. " - "Supports multi-collection search." + "Supports multi-collection search. Narrow results with metadata " + "filters (session_id, code_name, tool_name, source_type, ...) and/or a " + "document-text filter ('regex' / 'contains') over the chunk text " + "itself — useful for pulling specific session-memory context." ), inputSchema={ "type": "object", @@ -399,9 +418,13 @@ def _knowledge_tools_and_handlers(kb: KnowledgeBase): "type": "string", "description": "Filter by session ID (ChromaDB collections only)", }, + "code_name": { + "type": "string", + "description": "Filter by registered-code name — code execution records (ChromaDB collections only)", + }, "tool_name": { "type": "string", - "description": "Filter by tool name (ChromaDB collections only)", + "description": "Filter by agent tool-call name — session memory (ChromaDB collections only)", }, "source_type": { "type": "string", @@ -411,6 +434,21 @@ def _knowledge_tools_and_handlers(kb: KnowledgeBase): "type": "integer", "description": "Filter by tool exit code (ChromaDB collections only)", }, + "regex": { + "type": "string", + "description": ( + "Require chunk text to match this regular expression " + "(ChromaDB $regex). Use (?i) for case-insensitive, " + "e.g. '(?i)parser'. Narrows before semantic ranking." + ), + }, + "contains": { + "type": "string", + "description": ( + "Require chunk text to contain this case-sensitive " + "substring (ChromaDB $contains)." + ), + }, }, "required": ["query"], }, @@ -498,4 +536,6 @@ def create_knowledge_server(kb: KnowledgeBase): :func:`_knowledge_tools_and_handlers` directly instead of this wrapper. """ tools, handlers = _knowledge_tools_and_handlers(kb) - return build_dispatch_server("knowledge", tools, handlers) + return build_dispatch_server( + "knowledge", tools, handlers, {t: "knowledge" for t in handlers} + ) diff --git a/src/dsagt/mcp/memory_tools.py b/src/dsagt/mcp/memory_tools.py index c35be5f..e6cf161 100644 --- a/src/dsagt/mcp/memory_tools.py +++ b/src/dsagt/mcp/memory_tools.py @@ -1,11 +1,9 @@ -"""MCP tools for explicit memory + outlier suggestions. +"""MCP tools for explicit memory. User-confirmed facts that persist across sessions (``kb_remember`` / -``kb_get_memories``) and the outlier-detection suggestion queue -(``kb_get_suggestions`` / ``kb_dismiss_suggestion``). These front -:mod:`dsagt.memory` (``ExplicitMemory`` + ``SuggestionQueue``); the ``kb_`` -tool-name prefix is historical (the tools were born in the knowledge server) -and is kept for agent-facing backward compatibility. +``kb_get_memories``). These front :mod:`dsagt.memory` (``ExplicitMemory``); the +``kb_`` tool-name prefix is historical (the tools were born in the knowledge +server) and is kept for agent-facing backward compatibility. These definitions + handlers run inside the merged ``dsagt-server`` (see :mod:`dsagt.mcp.server`); ``create_memory_server`` is retained only as a @@ -21,7 +19,7 @@ from dsagt.knowledge import KnowledgeBase from dsagt.mcp.server import build_dispatch_server -from dsagt.memory import ExplicitMemory, SuggestionQueue +from dsagt.memory import ExplicitMemory logger = logging.getLogger(__name__) @@ -31,13 +29,11 @@ async def _handle_kb_remember( *, kb: KnowledgeBase, memory: ExplicitMemory, - suggestions: SuggestionQueue, ) -> dict: text = arguments["text"] category = arguments.get("category", "") session_id = arguments.get("session_id", "") supersedes = arguments.get("supersedes") - promoted_from = arguments.get("promoted_from") store_result = await asyncio.to_thread( memory.remember, @@ -76,14 +72,10 @@ async def _handle_kb_remember( e, ) - if promoted_from: - suggestions.dismiss(promoted_from) - return { "status": "ok", "entry_id": store_result["entry_id"], "superseded_id": store_result.get("superseded_id"), - "promoted_from": promoted_from, "total_memories": await asyncio.to_thread(memory.count), } @@ -92,36 +84,9 @@ async def _handle_kb_get_memories( arguments: dict, *, memory: ExplicitMemory, - suggestions: SuggestionQueue, ) -> dict: entries = await asyncio.to_thread(memory.get_all) - pending = suggestions.get_all() - result = {"status": "ok", "count": len(entries), "memories": entries} - if pending: - result["suggestions"] = pending - result["suggestion_count"] = len(pending) - return result - - -async def _handle_kb_get_suggestions( - arguments: dict, - *, - suggestions: SuggestionQueue, -) -> dict: - pending = suggestions.get_all() - return {"status": "ok", "count": len(pending), "suggestions": pending} - - -async def _handle_kb_dismiss_suggestion( - arguments: dict, - *, - suggestions: SuggestionQueue, -) -> dict: - suggestion_id = arguments["suggestion_id"] - dismissed = suggestions.dismiss(suggestion_id) - if not dismissed: - return {"status": "error", "error": f"Suggestion not found: {suggestion_id}"} - return {"status": "ok", "dismissed": suggestion_id, "remaining": suggestions.count} + return {"status": "ok", "count": len(entries), "memories": entries} # --------------------------------------------------------------------------- @@ -136,27 +101,16 @@ def _memory_tools_and_handlers( """Build the explicit-memory ``(tool defs, handler map)``. Combined with the other concern modules' tools under one MCP ``Server`` by - :func:`dsagt.mcp.server.create_dsagt_server`. ``ExplicitMemory`` + - ``SuggestionQueue`` are rooted at ``runtime_dir`` (falling back to the KB - index's parent), matching the project's ``.dsagt`` memory location. + :func:`dsagt.mcp.server.create_dsagt_server`. ``ExplicitMemory`` is rooted + at ``runtime_dir`` (falling back to the KB index's parent), matching the + project's ``.dsagt`` memory location. """ mem_dir = Path(runtime_dir) if runtime_dir else kb.index_dir.parent memory = ExplicitMemory(runtime_dir=mem_dir) - suggestions = SuggestionQueue(mem_dir / "suggestions.json") handlers = { - "kb_remember": partial( - _handle_kb_remember, kb=kb, memory=memory, suggestions=suggestions - ), - "kb_get_memories": partial( - _handle_kb_get_memories, memory=memory, suggestions=suggestions - ), - "kb_get_suggestions": partial( - _handle_kb_get_suggestions, suggestions=suggestions - ), - "kb_dismiss_suggestion": partial( - _handle_kb_dismiss_suggestion, suggestions=suggestions - ), + "kb_remember": partial(_handle_kb_remember, kb=kb, memory=memory), + "kb_get_memories": partial(_handle_kb_get_memories, memory=memory), } tools = [ @@ -185,10 +139,6 @@ def _memory_tools_and_handlers( "type": "string", "description": "entry_id of an existing memory this replaces", }, - "promoted_from": { - "type": "string", - "description": "suggestion_id if promoted from outlier suggestion", - }, }, "required": ["text"], }, @@ -201,28 +151,6 @@ def _memory_tools_and_handlers( ), inputSchema={"type": "object", "properties": {}}, ), - types.Tool( - name="kb_get_suggestions", - description=( - "Get pending memory suggestions flagged by outlier detection. " - "Present to user for confirmation or dismissal." - ), - inputSchema={"type": "object", "properties": {}}, - ), - types.Tool( - name="kb_dismiss_suggestion", - description="Dismiss a pending memory suggestion.", - inputSchema={ - "type": "object", - "properties": { - "suggestion_id": { - "type": "string", - "description": "ID of the suggestion to dismiss", - }, - }, - "required": ["suggestion_id"], - }, - ), ] return tools, handlers @@ -238,4 +166,6 @@ def create_memory_server( :func:`_memory_tools_and_handlers` directly instead of this wrapper. """ tools, handlers = _memory_tools_and_handlers(kb, runtime_dir) - return build_dispatch_server("memory", tools, handlers) + return build_dispatch_server( + "memory", tools, handlers, {t: "memory" for t in handlers} + ) diff --git a/src/dsagt/mcp/registry_tools.py b/src/dsagt/mcp/registry_tools.py index a1723ba..df58b54 100644 --- a/src/dsagt/mcp/registry_tools.py +++ b/src/dsagt/mcp/registry_tools.py @@ -1,7 +1,7 @@ """MCP tools for the tool registry, execution, and provenance. The "tool lifecycle" surface of ``dsagt-server``: define a tool spec -(``save_tool_spec``), discover tools (``get_registry`` / ``search_registry``), +(``save_code_spec``), discover tools (``get_registry`` / ``search_registry``), execute / gather (``read_file`` / ``http_request`` / ``run_command`` / ``install_dependencies``), and reconstruct a reproducible pipeline from the recorded executions (``reconstruct_pipeline``). @@ -35,10 +35,10 @@ obs, registry_install_deps_span, registry_reconstruct_pipeline_span, - registry_save_tool_span, + registry_save_code_span, ) -from dsagt.provenance import ToolUseIndexer, reconstruct_pipeline -from dsagt.registry import TOOLS_COLLECTION, ToolRegistry +from dsagt.provenance import CodeUseIndexer, reconstruct_pipeline +from dsagt.registry import CODES_COLLECTION, CodeRegistry logger = logging.getLogger(__name__) @@ -125,10 +125,10 @@ async def _handle_run_command(arguments: dict) -> str: return output -async def _handle_save_tool_spec( +async def _handle_save_code_spec( arguments: dict, *, - registry: ToolRegistry, + registry: CodeRegistry, ) -> str: spec = arguments["spec"] # Some MCP clients (notably Claude Sonnet/Haiku 4.x) serialize nested @@ -138,7 +138,7 @@ async def _handle_save_tool_spec( spec = json.loads(spec) except json.JSONDecodeError as e: return f"Error: spec must be a JSON object (or string-encoded JSON object): {e}" - with registry_save_tool_span(spec.get("name")): + with registry_save_code_span(spec.get("name")): obs.set("language", spec.get("language")) obs.set("n_dependencies", len(spec.get("dependencies") or [])) obs.set("n_tags", len(spec.get("tags") or [])) @@ -148,7 +148,7 @@ async def _handle_save_tool_spec( obs.event("save_tool_failed", error=str(e)[:256]) return f"Error saving tool spec: {e}" - tool_count = len(registry.list_tools_raw()) + tool_count = len(registry.list_codes_raw()) obs.set("action", action) obs.set("registry_size", tool_count) message = ( @@ -171,46 +171,46 @@ async def _handle_save_tool_spec( async def _handle_get_registry( arguments: dict, *, - registry: ToolRegistry, + registry: CodeRegistry, ) -> str: - tools = registry.list_tools_raw() + tools = registry.list_codes_raw() if not tools: return "Registry is empty. No tools registered yet." - return yaml.dump({"tools": tools}, default_flow_style=False, sort_keys=False) + return yaml.dump({"codes": tools}, default_flow_style=False, sort_keys=False) async def _handle_search_registry( arguments: dict, *, - registry: ToolRegistry, + registry: CodeRegistry, kb: KnowledgeBase | None, ) -> str: - tool_name = arguments.get("tool_name") + code_name = arguments.get("code_name") query = arguments.get("query", "") tag = arguments.get("tag") top_k = arguments.get("top_k", 10) - if tool_name: - tool = registry.get_tool(tool_name) + if code_name: + tool = registry.get_code(code_name) if tool: - return f"Found tool '{tool_name}':\n\n" + yaml.dump( + return f"Found tool '{code_name}':\n\n" + yaml.dump( tool, default_flow_style=False, sort_keys=False ) - return f"No tool named '{tool_name}'." + return f"No tool named '{code_name}'." if kb is None: return ( "search_registry requires a configured knowledge base " "(set embedding.api_key + embedding.base_url + embedding.model " - "in dsagt_config.yaml). Use search_registry with an exact " - "tool_name for KB-free lookups." + "in .dsagt/config.yaml). Use search_registry with an exact " + "code_name for KB-free lookups." ) # Single ``tools`` collection — bundled and registered entries # coexist, distinguished by ``metadata.source`` if needed. results = kb.search( query=query or "tool", - collection=TOOLS_COLLECTION, + collection=CODES_COLLECTION, top_k=top_k * 3 if tag else top_k, ) if tag and results: @@ -227,7 +227,7 @@ async def _handle_search_registry( chunk = r.get("chunk", {}) meta = chunk.get("metadata", {}) summaries.append( - f"- **{meta.get('tool_name', 'unknown')}** " + f"- **{meta.get('code_name', 'unknown')}** " f"(score: {r.get('score', 0):.2f})\n" f" {chunk.get('text', '')[:200]}" ) @@ -245,10 +245,10 @@ async def _handle_reconstruct_pipeline( # Index the session's tool-use first: reconstruct is the moment the pipeline # is "done enough" to review, so make the just-run executions searchable now # rather than waiting on the heartbeat. Idempotent + file-locked, so this - # is safe to fire alongside the heartbeat's own ToolUseIndexer. + # is safe to fire alongside the heartbeat's own CodeUseIndexer. if kb is not None: try: - ToolUseIndexer(kb, runtime_dir).tick() + CodeUseIndexer(kb, runtime_dir).tick() except Exception as e: # noqa: BLE001 — indexing is best-effort here logger.warning("tool_use indexing before reconstruct failed: %s", e) with registry_reconstruct_pipeline_span(fmt): @@ -264,40 +264,40 @@ async def _handle_reconstruct_pipeline( async def _handle_install_dependencies( arguments: dict, *, - registry: ToolRegistry, + registry: CodeRegistry, ) -> str: - tool_name = arguments.get("tool_name") - tools = registry.list_tools_raw() + code_name = arguments.get("code_name") + tools = registry.list_codes_raw() if not tools: return "Registry is empty. No tools registered yet." all_deps = [] - tools_with_deps = [] + codes_with_deps = [] for tool in tools: - if tool_name and tool.get("name") != tool_name: + if code_name and tool.get("name") != code_name: continue - tool_deps = tool.get("dependencies", []) - if tool_deps: - all_deps.extend(tool_deps) - tools_with_deps.append(tool["name"]) + code_deps = tool.get("dependencies", []) + if code_deps: + all_deps.extend(code_deps) + codes_with_deps.append(tool["name"]) if not all_deps: - scope = f"tool '{tool_name}'" if tool_name else "registry" + scope = f"tool '{code_name}'" if code_name else "registry" return f"No dependencies declared in {scope}." seen = set() unique_deps = [d for d in all_deps if not (d in seen or seen.add(d))] with registry_install_deps_span(unique_deps): - obs.set("scope_tool", tool_name) - obs.set("n_tools_with_deps", len(tools_with_deps)) + obs.set("scope_code", code_name) + obs.set("n_tools_with_deps", len(codes_with_deps)) result = _install_dependencies(unique_deps) if result.startswith("Successfully installed:"): obs.set("status", "ok") else: obs.set("status", "failed") obs.event("install_failed", message=result[:256]) - return f"Installing dependencies for: {', '.join(tools_with_deps)}\n\n{result}" + return f"Installing dependencies for: {', '.join(codes_with_deps)}\n\n{result}" # --------------------------------------------------------------------------- @@ -306,7 +306,7 @@ async def _handle_install_dependencies( def _registry_tools_and_handlers( - registry: ToolRegistry, + registry: CodeRegistry, kb: KnowledgeBase | None = None, ): """Build the registry/execution/provenance ``(tool defs, handler map)``. @@ -320,7 +320,7 @@ def _registry_tools_and_handlers( "read_file": _handle_read_file, "http_request": _handle_http_request, "run_command": _handle_run_command, - "save_tool_spec": partial(_handle_save_tool_spec, registry=registry), + "save_code_spec": partial(_handle_save_code_spec, registry=registry), "get_registry": partial(_handle_get_registry, registry=registry), "search_registry": partial(_handle_search_registry, registry=registry, kb=kb), "reconstruct_pipeline": partial( @@ -388,7 +388,7 @@ def _registry_tools_and_handlers( }, ), types.Tool( - name="save_tool_spec", + name="save_code_spec", description="Save a tool specification to the registry as a skill file", inputSchema={ "type": "object", @@ -483,7 +483,7 @@ def _registry_tools_and_handlers( "properties": { "query": {"type": "string", "description": "Search query"}, "tag": {"type": "string", "description": "Filter by tag"}, - "tool_name": { + "code_name": { "type": "string", "description": "Exact tool name lookup", }, @@ -511,7 +511,7 @@ def _registry_tools_and_handlers( inputSchema={ "type": "object", "properties": { - "tool_name": { + "code_name": { "type": "string", "description": "Install deps for a specific tool (omit for all)", }, @@ -523,7 +523,7 @@ def _registry_tools_and_handlers( def create_registry_server( - registry: ToolRegistry, + registry: CodeRegistry, kb: KnowledgeBase | None = None, ): """Create a standalone MCP server exposing only the registry/exec/provenance tools. @@ -533,4 +533,6 @@ def create_registry_server( :func:`_registry_tools_and_handlers` directly instead of this wrapper. """ tools, handlers = _registry_tools_and_handlers(registry, kb) - return build_dispatch_server("registry", tools, handlers) + return build_dispatch_server( + "registry", tools, handlers, {t: "registry" for t in handlers} + ) diff --git a/src/dsagt/mcp/server.py b/src/dsagt/mcp/server.py index f291f70..e36eac0 100644 --- a/src/dsagt/mcp/server.py +++ b/src/dsagt/mcp/server.py @@ -50,7 +50,8 @@ from mcp.server.models import InitializationOptions # noqa: E402 from dsagt.knowledge import KnowledgeBase # noqa: E402 -from dsagt.registry import SkillRegistry, ToolRegistry # noqa: E402 +from dsagt.observability import open_span # noqa: E402 +from dsagt.registry import SkillRegistry, CodeRegistry # noqa: E402 logger = logging.getLogger(__name__) @@ -61,7 +62,9 @@ # --------------------------------------------------------------------------- -def build_dispatch_server(name: str, tools, handlers) -> Server: +def build_dispatch_server( + name: str, tools, handlers, tool_category: dict[str, str] | None = None +) -> Server: """Wrap a ``(tools, handlers)`` pair in a configured MCP ``Server``. One dispatch contract for every concern module: catch + wrap errors, then @@ -70,7 +73,16 @@ def build_dispatch_server(name: str, tools, handlers) -> Server: per-server behavior (registry handlers returned ``str`` and never raised; knowledge handlers returned ``dict`` and raised ``ValueError`` on bad input), so it is behavior-preserving for both. + + ``tool_category`` maps tool name → concern (``memory`` / ``skill`` / + ``knowledge`` / ``registry``). Each call opens one categorization-root span + (named for the tool) tagged ``dsagt.source=``; the subsystem spans + the handler opens (``kb.*`` / ``registry.*``) nest under it — and inherit the + category, so the source reflects the tool the agent called, not whichever + subsystem did the work. Tracing no-ops outside a project, so this is inert + in the single-concern test servers / one-shot tools. """ + tool_category = tool_category or {} server = Server(name) @server.list_tools() @@ -80,13 +92,14 @@ async def list_tools() -> list[types.Tool]: @server.call_tool() async def call_tool(tool_name: str, arguments: dict) -> list[types.TextContent]: handler = handlers[tool_name] # KeyError = bug in list_tools schema - try: - result = await handler(arguments) - except ValueError as e: - result = {"status": "error", "error": str(e)} - except Exception as e: - logger.exception("Unexpected error in tool '%s'", tool_name) - result = {"status": "error", "error": f"Unexpected error: {e}"} + with open_span(tool_name, source=tool_category.get(tool_name)): + try: + result = await handler(arguments) + except ValueError as e: + result = {"status": "error", "error": str(e)} + except Exception as e: + logger.exception("Unexpected error in tool '%s'", tool_name) + result = {"status": "error", "error": f"Unexpected error: {e}"} text = ( result if isinstance(result, str) @@ -100,14 +113,20 @@ async def call_tool(tool_name: str, arguments: dict) -> list[types.TextContent]: HEARTBEAT_INTERVAL_S = 45.0 -async def _heartbeat(collector, tool_indexer, interval: float) -> None: +async def _heartbeat(collector, tool_indexer, interval: float, project_dir) -> None: """Periodically run the trace collector + tool-use indexer on wall-clock time. Runs regardless of tool traffic, so a quiet session (the agent thinking, editing with its own tools, plain chat) is still captured. Both block on disk (+ MLflow / embedding), so they run in a worker thread to keep handlers responsive; a failure is logged, never fatal. + + It also records the live session's trace-source token into ``state.yaml`` + once resolved, so the *next* session's startup catch-up can pin this exact + session even if this one is killed ungracefully (the deferred final-turn + flush never runs). Uniform across agents — JSONL or SQLite. """ + recorded = False while True: await asyncio.sleep(interval) if collector is not None: @@ -117,6 +136,16 @@ async def _heartbeat(collector, tool_indexer, interval: float) -> None: logger.info("Trace heartbeat: logged %d trace(s)", n) except Exception as e: # noqa: BLE001 logger.warning("Trace heartbeat collect failed: %s", e) + if not recorded: + try: + source = collector.active_source() + if source is not None: + from dsagt.session import record_trace_source + + record_trace_source(project_dir, source) + recorded = True + except Exception as e: # noqa: BLE001 + logger.debug("Could not record trace source: %s", e) if tool_indexer is not None: try: n = await asyncio.to_thread(tool_indexer.tick) @@ -126,48 +155,15 @@ async def _heartbeat(collector, tool_indexer, interval: float) -> None: logger.warning("Tool-use heartbeat tick failed: %s", e) -def _episodic_consumers(config, kb, project_dir, session_id): - """Build the episodic-memory consumer list from config (empty when off). - - Episodic memory is a compute/storage opt-in (``episodic.enabled``); the - Tier-1 ``Judge`` is attached only when a backend is configured, otherwise - the consumer runs Tier-0 (mechanical, no LLM). Best-effort: a build - failure leaves the collector with just the MLflow logger. - """ - epi = config.get("episodic", {}) or {} - if not epi.get("enabled"): - return [] - try: - from dsagt.memory import MemoryExtractor - - judge = None - jcfg = epi.get("judge", {}) or {} - if jcfg.get("backend"): - from dsagt.judge import Judge - - judge = Judge.create(jcfg["backend"], model=jcfg.get("model") or None) - return [ - MemoryExtractor( - kb, - runtime_dir=str(project_dir), - session_id=session_id or "", - tags=epi.get("domain_tags") or None, - judge=judge, - outlier_sensitivity=float(epi.get("outlier_sensitivity", 0.0) or 0.0), - ) - ] - except Exception as e: # noqa: BLE001 — memory is best-effort, never fatal - logger.warning("Could not build episodic-memory consumer: %s", e) - return [] - - async def _run_stdio( - server: Server, name: str, collector=None, tool_indexer=None + server: Server, name: str, collector=None, tool_indexer=None, project_dir=None ) -> None: async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): hb = ( asyncio.create_task( - _heartbeat(collector, tool_indexer, HEARTBEAT_INTERVAL_S) + _heartbeat( + collector, tool_indexer, HEARTBEAT_INTERVAL_S, project_dir + ) ) if (collector is not None or tool_indexer is not None) else None @@ -193,9 +189,12 @@ async def _run_stdio( except asyncio.CancelledError: pass # Best-effort end-of-session flush of the deferred final turn + - # any unindexed tool-use. Non-load-bearing: if killed, the next - # session's startup catch-up re-reads the tail (both ack-sets - # make it idempotent). + # any unindexed tool-use — covers the graceful-exit case. If + # this is killed before it runs, the next session's startup + # catch-up re-collects the previous session's transcript + # (session.catch_up_extraction → _catch_up_traces, pinned to the + # recorded transcript path); session-qualified acks make both + # paths idempotent. Tool-use likewise re-indexes via its ack set. if collector is not None: try: await asyncio.to_thread(collector.collect, include_last=True) @@ -214,7 +213,7 @@ async def _run_stdio( def create_dsagt_server( - registry: ToolRegistry, + registry: CodeRegistry, kb: KnowledgeBase | None, skill_registry: SkillRegistry | None, runtime_dir: str | Path | None = None, @@ -232,16 +231,19 @@ def create_dsagt_server( from dsagt.mcp.registry_tools import _registry_tools_and_handlers from dsagt.mcp.skill_tools import _skill_tools_and_handlers + # (category, group) — the category is the dsagt.source bucket stamped on + # every trace rooted at one of that group's tools. groups = [ - _registry_tools_and_handlers(registry, kb), - _knowledge_tools_and_handlers(kb), - _memory_tools_and_handlers(kb, runtime_dir), - _skill_tools_and_handlers(skill_registry, kb, runtime_dir), + ("registry", _registry_tools_and_handlers(registry, kb)), + ("knowledge", _knowledge_tools_and_handlers(kb)), + ("memory", _memory_tools_and_handlers(kb, runtime_dir)), + ("skill", _skill_tools_and_handlers(skill_registry, kb, runtime_dir)), ] tools: list[types.Tool] = [] handlers: dict = {} - for g_tools, g_handlers in groups: + tool_category: dict[str, str] = {} + for category, (g_tools, g_handlers) in groups: overlap = set(handlers) & set(g_handlers) if overlap: raise RuntimeError( @@ -249,8 +251,10 @@ def create_dsagt_server( ) tools += g_tools handlers.update(g_handlers) + for tool_name in g_handlers: + tool_category[tool_name] = category - return build_dispatch_server("dsagt", tools, handlers) + return build_dispatch_server("dsagt", tools, handlers, tool_category) def _build_kb_from_config(config: dict, project_dir: Path) -> KnowledgeBase: @@ -429,9 +433,9 @@ def main(): # Bundled tools are pre-embedded in the shared ~/dsagt-projects/kb_index/ # by ``dsagt init`` (shared cache, one-time per machine) and # copied into the project's kb_index by ``setup_runtime_kb`` above. No - # bundled embedding work happens here; save_tool_spec incurs a single + # bundled embedding work happens here; save_code_spec incurs a single # embed at save time. - registry = ToolRegistry( + registry = CodeRegistry( source_tools_dir=None, runtime_dir=str(project_dir), kb=kb, @@ -451,6 +455,7 @@ def main(): # Best-effort — a collector that can't be built never blocks the server. collector = None try: + from dsagt.memory import episodic_consumers from dsagt.observability import resolve_tracking_uri from dsagt.traces import make_trace_collector @@ -462,7 +467,7 @@ def main(): config.get("project", ""), session_id or "", resolve_tracking_uri(resolve_cfg), - extra_consumers=_episodic_consumers(config, kb, project_dir, session_id), + extra_consumers=episodic_consumers(config, kb, project_dir, session_id), ) except Exception as e: # noqa: BLE001 logger.warning("Could not start trace heartbeat: %s", e) @@ -472,14 +477,16 @@ def main(): # dependency — it reads trace_archive/, not the transcript). tool_indexer = None try: - from dsagt.provenance import ToolUseIndexer + from dsagt.provenance import CodeUseIndexer - tool_indexer = ToolUseIndexer(kb, project_dir) + tool_indexer = CodeUseIndexer(kb, project_dir) except Exception as e: # noqa: BLE001 logger.warning("Could not start tool-use indexer: %s", e) try: - asyncio.run(_run_stdio(server, "dsagt", collector, tool_indexer)) + asyncio.run( + _run_stdio(server, "dsagt", collector, tool_indexer, project_dir) + ) finally: kb.close() diff --git a/src/dsagt/mcp/skill_tools.py b/src/dsagt/mcp/skill_tools.py index 2bc195c..56ba75e 100644 --- a/src/dsagt/mcp/skill_tools.py +++ b/src/dsagt/mcp/skill_tools.py @@ -243,14 +243,14 @@ def _skill_tools_and_handlers( "Register a skill (agent workflow / instructions) into " "/skills//SKILL.md, where the agent natively " "auto-discovers it after the next `dsagt start`. Symmetric " - "with save_tool_spec — use this when you've designed a " + "with save_code_spec — use this when you've designed a " "reusable instruction set you want future sessions to load " "automatically." ), inputSchema={ "type": "object", "properties": { - # ``anyOf`` for spec mirrors save_tool_spec — accept + # ``anyOf`` for spec mirrors save_code_spec — accept # both structured object and JSON-encoded string for # MCP clients that serialize nested args. "spec": { @@ -383,4 +383,6 @@ def create_skill_server( :func:`_skill_tools_and_handlers` directly instead of this wrapper. """ tools, handlers = _skill_tools_and_handlers(skill_registry, kb, runtime_dir) - return build_dispatch_server("skills", tools, handlers) + return build_dispatch_server( + "skills", tools, handlers, {t: "skill" for t in handlers} + ) diff --git a/src/dsagt/memory.py b/src/dsagt/memory.py index d1dabe1..19c5ec9 100644 --- a/src/dsagt/memory.py +++ b/src/dsagt/memory.py @@ -10,51 +10,30 @@ demand via the ``kb_get_memories`` / ``kb_search`` MCP tools. Supports remember, supersede, remove, and retrieval. -**Episodic memory** (MemoryExtractor — Phase 3): +**Episodic memory** (MemoryExtractor): A trace-pipeline *consumer* (``MemoryExtractor``) that consumes the - in-process ``Trace`` the heartbeat produces and writes tagged - facts into the ``session_memory`` collection. Two tiers: - - * **Tier-0 (mechanical, always):** chunk + mechanical-tag + embed - each turn — no LLM, the universal fallback for every agent. - * **Tier-1 (distilled, opt-in):** a small local LLM (``judge.Judge`` - — ``LocalJudge`` by default) tags + condenses each turn into - ≤1-sentence facts. On judge failure it degrades to Tier-0 — never - lose data, never block. - - The per-category-centroid outlier detection (``CategoryCentroids`` / - ``SuggestionQueue``) gives principled, user-confirmed novelty review on - top of the distilled facts. ``extract_session`` (the old end-of-session - entry point) remains a no-op stub: the heartbeat consumer is the live - path; ``extract_session`` is retained only for the deferred cross-session - N+1 catch-up call site. + in-process ``Trace`` the heartbeat produces and writes per-block chunks into + the ``session_memory`` collection (producer/tool/turn_id metadata, no LLM). Files on disk (in project directory): explicit_memories.yaml — active user-confirmed facts explicit_memories_history.yaml — superseded/removed entries - centroids.json — per-category centroid vectors and counts - suggestions.json — queued outlier facts awaiting user review """ from __future__ import annotations import hashlib -import json import logging -import re import time from datetime import datetime, timezone from pathlib import Path from typing import TYPE_CHECKING -import numpy as np - import yaml from dsagt.knowledge import KnowledgeBase if TYPE_CHECKING: - from dsagt.judge import Judge from dsagt.traces import Trace logger = logging.getLogger(__name__) @@ -200,75 +179,24 @@ def render_context(self) -> str: # Episodic memory: constants # --------------------------------------------------------------------------- -#: Project-local collection holding extracted-at-end-of-session facts, -#: insights, and summaries. Renamed from ``episodic_memory`` to match -#: the user-facing terminology in ``dsagt info`` ("session memory"). +#: Project-local collection holding the mechanically-indexed session turns. +#: Named ``session_memory`` to match the user-facing terminology in +#: ``dsagt info`` ("session memory"). SESSION_MEMORY_COLLECTION = "session_memory" -#: Backwards-compat alias. New code should use ``SESSION_MEMORY_COLLECTION``. -EPISODIC_COLLECTION = SESSION_MEMORY_COLLECTION - -# session_memory inherits the kb's default backend / vector_db — it -# used to hardcode ``embedding_backend="api"`` from when embedding was -# assumed to be API-only, but that forced ``kb_remember`` to retry -# against the (possibly invalid) embedding API even when the project -# was otherwise configured for local embeddings, hanging the agent -# for ~60s per call on the retry backoff. - -# The stock "AI-data-ready" taxonomy: a small, closed, domain-neutral label set -# — the *closedness* is what makes the Tier-1 LocalJudge viable (small models -# classify into a fixed set reliably; "invent a category" is what they fail at). -# Init solicits per-project domain tags that merge on top (the genomics-specific -# ``assembly`` that used to live here is now exactly such a user tag, not stock). -STOCK_CATEGORIES = { - "quality_control": "Assessment or filtering of data quality, QC metrics, thresholds, pass/fail rates", - "data_management": "File organization, data movement, format conversion, naming conventions", - "transformation": "Data processing steps, parameter choices, pipeline stage configuration", - "configuration": "Tool settings, environment setup, resource allocation decisions", - "performance": "Runtime, memory usage, throughput, resource consumption observations", - "tool_usage": "Tool selection rationale, parameter tuning, tool-specific behaviors or quirks", - "results": "Output summaries, key findings, deliverables produced", -} - -DEFAULT_SENSITIVITY = 0.35 - # --------------------------------------------------------------------------- -# Extraction prompt construction +# Trace chunking — split a turn's blocks into embeddable chunks # -# Prompt/parse building blocks for the Phase-3 Trace-based -# extractor (see ``extract_session``). +# The trace already carries mechanical boundaries (messages → content blocks), +# so a "chunk" is one block: a user/assistant text block or a tool_result, each +# embedded separately with a ``producer`` (user/llm/tool) label. tool_use blocks +# aren't embedded — they only supply the ``tool`` name resolved onto the matching +# tool_result. Over-long blocks split on newlines (cheap, boundary-meaningful). # --------------------------------------------------------------------------- - -def _render_conversation(exchanges: list[dict]) -> str: - lines = [] - for i, ex in enumerate(exchanges, 1): - lines.append(f"=== Exchange {i} ({ex.get('timestamp', '')}) ===") - - for msg in ex.get("new_messages", []): - role = msg.get("role", "unknown") - content = msg.get("content", "") - if isinstance(content, list): - parts = [] - for block in content: - if block.get("type") == "text": - parts.append(block.get("text", "")) - elif block.get("type") == "tool_result": - parts.append(f"[tool_result: {_extract_block_text(block)}]") - content = "\n".join(parts) - lines.append(f"[{role}] {content}") - - for block in ex.get("response", []): - if block.get("type") == "text": - lines.append(f"[assistant] {block.get('text', '')}") - elif block.get("type") == "tool_use": - name = block.get("name", "") - inp = json.dumps(block.get("input", {})) - lines.append(f"[assistant → tool_use: {name}({inp})]") - - lines.append("") - return "\n".join(lines) +#: Char budget above which a block is split on newlines before embedding. +_MAX_CHUNK_CHARS = 1200 def _extract_block_text(block: dict) -> str: @@ -284,271 +212,95 @@ def _extract_block_text(block: dict) -> str: return str(content) -def _format_categories(categories: dict[str, str]) -> str: - lines = [] - for name, description in categories.items(): - lines.append(f"- {name}: {description}") - return "\n".join(lines) - - -def build_extraction_prompt( - exchanges: list[dict], - categories: dict[str, str] | None = None, -) -> str: - """Build the extraction prompt from session log exchanges.""" - cats = {**STOCK_CATEGORIES, **(categories or {})} - conversation = _render_conversation(exchanges) - category_list = _format_categories(cats) - - return f"""\ -You are a memory extraction system for a data pipeline building assistant. \ -Review the following session conversation to: - -1) Extract relevant facts — short declarative statements about what happened, \ -what was decided, what parameters were used, what succeeded or failed, \ -and any observations worth remembering for future sessions. - -2) Provide a complete summary of the session — the overall flow, what was \ -accomplished, key decisions made, and deliverables produced. - -3) Reflect across the facts and summary to synthesize insights — patterns, \ -lessons learned, generalizable observations that would help in future sessions \ -with similar data or tools. - -4) Classify each fact and insight into one of these categories: +def _split_oversized(text: str) -> list[str]: + """Split an over-long block at meaningful boundaries — no tokenizer. -{category_list} - -Output ONLY a JSON object with this structure (no markdown, no preamble): - -{{ - "facts": [ - {{"text": "short declarative fact", "category": "category_name"}}, - ... - ], - "summary": "paragraph-length session summary", - "insights": [ - {{"text": "generalizable insight", "category": "category_name"}}, - ... - ] -}} - -Session conversation: - -{conversation}""" - - -# --------------------------------------------------------------------------- -# Response parsing -# --------------------------------------------------------------------------- - - -def parse_extraction_response(response_text: str) -> dict: - """Parse the LLM's JSON response into facts, summary, and insights.""" - text = response_text.strip() - if text.startswith("```"): - text = text.split("\n", 1)[1] if "\n" in text else text[3:] - if text.endswith("```"): - text = text[:-3] + Paragraph (``\\n\\n``) first, then line (``\\n``), greedily packing pieces up + to :data:`_MAX_CHUNK_CHARS`; a no-newline blob is hard-sliced as a last + resort. Short blocks pass through untouched (stripped). + """ text = text.strip() - - parsed = json.loads(text) - - return { - "facts": parsed.get("facts", []), - "summary": parsed.get("summary", ""), - "insights": parsed.get("insights", []), - } - - -# --------------------------------------------------------------------------- -# Outlier detection: category centroids -# --------------------------------------------------------------------------- - - -class CategoryCentroids: - """Maintains running centroids per category.""" - - def __init__(self, path: Path): - self._path = path - self._data: dict[str, dict] = {} - if path.exists(): - self._data = json.loads(path.read_text()) - - def update(self, category: str, embedding: np.ndarray) -> float: - """Update centroid, return cosine distance before update.""" - vec = embedding.astype(np.float64) - - if category not in self._data: - self._data[category] = {"centroid": vec.tolist(), "count": 1} - return 0.0 - - entry = self._data[category] - old_centroid = np.array(entry["centroid"], dtype=np.float64) - count = entry["count"] - - distance = self._cosine_distance(vec, old_centroid) - - new_centroid = (old_centroid * count + vec) / (count + 1) - norm = np.linalg.norm(new_centroid) - if norm > 0: - new_centroid /= norm - - entry["centroid"] = new_centroid.tolist() - entry["count"] = count + 1 - - return float(distance) - - def save(self) -> None: - self._path.parent.mkdir(parents=True, exist_ok=True) - self._path.write_text(json.dumps(self._data, indent=2)) - - @staticmethod - def _cosine_distance(a: np.ndarray, b: np.ndarray) -> float: - dot = np.dot(a, b) - norm_a = np.linalg.norm(a) - norm_b = np.linalg.norm(b) - if norm_a == 0 or norm_b == 0: - return 1.0 - return float(1.0 - dot / (norm_a * norm_b)) - - @property - def categories(self) -> list[str]: - return list(self._data.keys()) - - def count(self, category: str) -> int: - entry = self._data.get(category) - return entry["count"] if entry else 0 - - -# --------------------------------------------------------------------------- -# Outlier detection: suggestion queue -# --------------------------------------------------------------------------- - - -class SuggestionQueue: - """Manages pending outlier suggestions on disk.""" - - def __init__(self, path: Path): - self._path = path - self._suggestions: list[dict] = [] - if path.exists(): - self._suggestions = json.loads(path.read_text()) - - def add( - self, text: str, category: str, distance: float, session_id: str = "" - ) -> str: - suggestion_id = ( - "sug_" - + hashlib.sha256(f"{text}:{category}:{session_id}".encode()).hexdigest()[:8] - ) - - self._suggestions.append( - { - "id": suggestion_id, - "text": text, - "category": category, - "distance": round(distance, 4), - "session_id": session_id, - "timestamp": datetime.now(timezone.utc).isoformat(), - } - ) - self._save() - return suggestion_id - - def dismiss(self, suggestion_id: str) -> bool: - before = len(self._suggestions) - self._suggestions = [s for s in self._suggestions if s["id"] != suggestion_id] - if len(self._suggestions) < before: - self._save() - return True - return False - - def get_all(self) -> list[dict]: - return list(self._suggestions) - - def clear(self) -> int: - count = len(self._suggestions) - self._suggestions = [] - self._save() - return count - - @property - def count(self) -> int: - return len(self._suggestions) - - def _save(self) -> None: - self._path.parent.mkdir(parents=True, exist_ok=True) - self._path.write_text(json.dumps(self._suggestions, indent=2)) - - -# --------------------------------------------------------------------------- -# Outlier detection: check and queue -# --------------------------------------------------------------------------- - - -def check_and_queue_outliers( - texts: list[str], - categories: list[str], - embeddings: np.ndarray, - centroids: CategoryCentroids, - queue: SuggestionQueue, - threshold: float = DEFAULT_SENSITIVITY, - session_id: str = "", -) -> list[str]: - """Check facts against centroids, queue outliers. Returns suggestion IDs.""" - suggestion_ids = [] - - for text, category, embedding in zip(texts, categories, embeddings): - if not category: + if not text: + return [] + if len(text) <= _MAX_CHUNK_CHARS: + return [text] + sep = "\n\n" if "\n\n" in text else ("\n" if "\n" in text else None) + if sep is None: + return [ + text[i : i + _MAX_CHUNK_CHARS] + for i in range(0, len(text), _MAX_CHUNK_CHARS) + ] + chunks: list[str] = [] + cur = "" + for piece in (p.strip() for p in text.split(sep)): + if not piece: continue + cur = f"{cur}{sep}{piece}" if cur else piece + while len(cur) > _MAX_CHUNK_CHARS: + chunks.append(cur[:_MAX_CHUNK_CHARS]) + cur = cur[_MAX_CHUNK_CHARS:].lstrip() + if cur: + chunks.append(cur) + return chunks - distance = centroids.update(category, embedding) - - if centroids.count(category) <= 1: - continue - if distance > threshold: - sid = queue.add( - text=text, category=category, distance=distance, session_id=session_id - ) - suggestion_ids.append(sid) - logger.info( - "Outlier flagged (distance=%.3f, threshold=%.3f): %s", - distance, - threshold, - text[:80], - ) +def _resolve_tool_names(exchanges: list[dict]) -> dict[str, str]: + """Map ``tool_use_id`` → tool name across a batch. - centroids.save() - return suggestion_ids + A tool call and its result land in *different* turns (the result is the next + turn's input), so the lookup is built over the whole delivered batch, not a + single exchange. + """ + names: dict[str, str] = {} + for ex in exchanges: + blocks = list(ex.get("response", [])) + for msg in ex.get("new_messages", []): + content = msg.get("content") + if isinstance(content, list): + blocks.extend(content) + for b in blocks: + if isinstance(b, dict) and b.get("type") == "tool_use": + names[b.get("id", "")] = b.get("name", "") + return names -# --------------------------------------------------------------------------- -# Mechanical (Tier-0) tagging -# --------------------------------------------------------------------------- +def _turn_chunks(exchange: dict, tool_names: dict[str, str]) -> list[dict]: + """One turn's blocks → ``[{text, producer, tool}]`` chunks. -_WORD_RE = re.compile(r"[a-z][a-z0-9_]{2,}") + text blocks carry the message's producer (``user``/``llm``); tool_result + blocks are ``tool`` with the name resolved via ``tool_use_id``; tool_use + blocks are skipped (captured in ``tool_names``, not embedded as prose). + """ + chunks: list[dict] = [] + + def emit(text: str, producer: str, tool_name: str) -> None: + for piece in _split_oversized(text or ""): + chunks.append({"text": piece, "producer": producer, "tool_name": tool_name}) + + def emit_block(block: dict, text_producer: str) -> None: + bt = block.get("type") + if bt == "text": + emit(block.get("text", ""), text_producer, "") + elif bt == "tool_result": + emit( + _extract_block_text(block), + "tool", + tool_names.get(block.get("tool_use_id", ""), ""), + ) + for msg in exchange.get("new_messages", []): + producer = "user" if msg.get("role", "user") == "user" else "llm" + content = msg.get("content", "") + if isinstance(content, str): + emit(content, producer, "") + else: + for block in content: + emit_block(block, producer) -def _mechanical_tag(text: str, tags: dict[str, str]) -> str: - """Pick the best-matching tag by keyword overlap, or ``""`` for none. + for block in exchange.get("response", []): + emit_block(block, "llm") - Tier-0's no-LLM classifier: score each tag by how many of its description - words appear in the turn text, take the best non-zero. Coarse by design — - Tier-1's LocalJudge is the accurate path; this only has to be *useful* as - the universal fallback, and an empty tag (uncategorized) is an honest result - when nothing matches rather than a forced wrong label. - """ - words = set(_WORD_RE.findall(text.lower())) - if not words: - return "" - best_tag, best_score = "", 0 - for name, desc in tags.items(): - score = len(words & set(_WORD_RE.findall(desc.lower()))) - if score > best_score: - best_tag, best_score = name, score - return best_tag + return chunks # --------------------------------------------------------------------------- @@ -566,27 +318,40 @@ def _epoch_or_now(ts: object) -> float: return float(ts) if isinstance(ts, (int, float)) else time.time() -def _batch_epoch(exchanges: list[dict]) -> float: - """The most-recent exchange timestamp in a turn batch (for distilled facts).""" - stamps = [ - e["timestamp"] - for e in exchanges - if isinstance(e.get("timestamp"), (int, float)) - ] - return max(stamps) if stamps else time.time() +def episodic_consumers(config: dict, kb, runtime_dir, session_id) -> list: + """Build the episodic-memory consumer list from config (empty when off). + + Shared by the live heartbeat (current session) and the startup catch-up + (previous session) so both feed the same :class:`MemoryExtractor` shape. + Episodic memory is a compute/storage opt-in (``episodic.enabled``). + Best-effort: a build failure returns ``[]`` rather than raising. + """ + epi = config.get("episodic", {}) or {} + if not epi.get("enabled"): + return [] + try: + return [ + MemoryExtractor( + kb, + runtime_dir=str(runtime_dir), + session_id=session_id or "", + ) + ] + except Exception as e: # noqa: BLE001 — memory is best-effort, never fatal + logger.warning("Could not build episodic-memory consumer: %s", e) + return [] class MemoryExtractor: - """Trace-pipeline consumer: ``Trace`` → tagged ``session_memory`` facts. + """Trace-pipeline consumer: ``Trace`` → ``session_memory`` chunks. Plugged into :class:`~dsagt.traces.TraceCollector` alongside the MLflow sink; each ``write`` receives the (subset of) just-completed turns and indexes them. Idempotency is the heartbeat's job — it only delivers turns this consumer hasn't acked — so ``write`` just does the work. - Tier selection: with a ``judge`` it runs Tier-1 (distilled facts) and falls - back to Tier-0 (mechanical chunk) if the judge raises; without one it runs - Tier-0 directly. Either way nothing is lost and the agent is never blocked. + Chunks each turn per-block and embeds it — no LLM, nothing lost, the agent + is never blocked. """ #: Subscriber name → its own ack file (``.dsagt/trace_acks_memory.json``). @@ -598,144 +363,44 @@ def __init__( *, runtime_dir: str | Path, session_id: str = "", - tags: dict[str, str] | None = None, - judge: "Judge | None" = None, - outlier_sensitivity: float = DEFAULT_SENSITIVITY, ): self._kb = kb self._runtime_dir = Path(runtime_dir) self._session_id = session_id - self._tags = {**STOCK_CATEGORIES, **(tags or {})} - self._judge = judge - self._sensitivity = outlier_sensitivity def write(self, trace: "Trace") -> None: exchanges = trace.to_exchanges() if not exchanges: return - if self._judge is None: - self._index_tier0(exchanges) - return - try: - facts = self._judge.distill(exchanges, self._tags) - except Exception as e: # designed degradation (plan §5), not a swallow - # *Judge* failure (not a store error) degrades to Tier-0 so the turn - # is never lost. A store-layer failure below is left to propagate — - # TraceCollector's per-consumer isolation stops it blocking the agent, - # and Tier-0 would hit the same KB anyway. - logger.warning("Tier-1 judge failed (%s); falling back to Tier-0", e) - self._index_tier0(exchanges) - return - # A judge that legitimately found nothing durable (most turns) stores - # nothing — that's the empty escape, not a failure, so no Tier-0 redo. - self._store_facts(facts, _batch_epoch(exchanges)) - - def _index_tier0(self, exchanges: list[dict]) -> None: - """Mechanical: render each turn, mechanically tag it, embed into session_memory.""" + # Categorization root: this runs on the heartbeat, outside any MCP + # dispatch, so tag the whole extraction ``dsagt.source=memory`` — the + # nested kb.* writes inherit it (otherwise they'd land uncategorized). + # (This tags the *observability* span, not the memory chunks.) + from dsagt.observability import open_span + + with open_span("memory.extract", source="memory"): + self._index_turns(exchanges) + + def _index_turns(self, exchanges: list[dict]) -> None: + """Chunk each turn per-block and embed into session_memory.""" + tool_names = _resolve_tool_names(exchanges) texts, metas = [], [] for ex in exchanges: - text = _render_conversation([ex]).strip() - if not text: - continue - texts.append(text) - metas.append( - { - "session_id": self._session_id, - "source_type": "turn", - "category": _mechanical_tag(text, self._tags), - "tier": "0", - "ts_epoch": _epoch_or_now(ex.get("timestamp")), - } - ) + turn_id = ex.get("turn_id", "") + ts_epoch = _epoch_or_now(ex.get("timestamp")) + for chunk in _turn_chunks(ex, tool_names): + texts.append(chunk["text"]) + metas.append( + { + "session_id": self._session_id, + "source_type": "turn", + "turn_id": turn_id, + "producer": chunk["producer"], + "tool_name": chunk["tool_name"], + "ts_epoch": ts_epoch, + } + ) if texts: self._kb.add_entries( texts=texts, collection=SESSION_MEMORY_COLLECTION, metadatas=metas ) - - def _store_facts(self, facts: list[dict], ts_epoch: float) -> None: - """Store Tier-1 distilled ``[{text, tag}]`` facts + outlier-detect.""" - if not facts: - return - texts = [f["text"] for f in facts] - cats = [f.get("tag", "") for f in facts] - metas = [ - { - "session_id": self._session_id, - "source_type": "fact", - "category": c, - "tier": "1", - "ts_epoch": ts_epoch, - } - for c in cats - ] - need_embeddings = self._sensitivity > 0 - result = self._kb.add_entries( - texts=texts, - collection=SESSION_MEMORY_COLLECTION, - metadatas=metas, - return_embeddings=need_embeddings, - ) - if need_embeddings: - check_and_queue_outliers( - texts=texts, - categories=cats, - embeddings=result["embeddings"], - centroids=CategoryCentroids(self._runtime_dir / "centroids.json"), - queue=SuggestionQueue(self._runtime_dir / "suggestions.json"), - threshold=self._sensitivity, - session_id=self._session_id, - ) - - -# --------------------------------------------------------------------------- -# End-to-end extraction -# --------------------------------------------------------------------------- - - -def extract_session( - project_name: str, - kb: KnowledgeBase, - api_key: str, - model: str = "claude-sonnet-4-20250514", - base_url: str | None = None, - provider: str | None = None, - categories: dict[str, str] | None = None, - session_id: str | None = None, - runtime_dir: Path | None = None, - outlier_sensitivity: float = 0.0, - mlflow_uri: str | None = None, - exchanges: list[dict] | None = None, -) -> dict: - """Episodic-memory extraction — a no-op stub until Phase 3. - - Phase 3 builds this over the ``Trace`` pipeline (Tier-0 - mechanical chunk/tag/embed by default, opt-in LLM distillation), - reusing the prompt/parse helpers and outlier detection in this module. - - The full signature is kept so ``session.catch_up_extraction`` and tests - keep their call site stable when the pipeline lands. Returns a status - dict reporting that extraction is unavailable; tool-execution indexing - (the other half of catch-up work) is unaffected. - """ - del ( - project_name, - kb, - api_key, - model, - base_url, - provider, - categories, - runtime_dir, - outlier_sensitivity, - mlflow_uri, - exchanges, - ) - return { - "status": "extraction_unavailable", - "facts": 0, - "insights": 0, - "summary": 0, - "suggestions": 0, - "total_entries": 0, - "session_id": session_id or "", - } diff --git a/src/dsagt/observability.py b/src/dsagt/observability.py index 3711cb8..8a8fa7b 100644 --- a/src/dsagt/observability.py +++ b/src/dsagt/observability.py @@ -1,44 +1,46 @@ """ -DSAgt observability — span emission via MLflow's native tracer provider. - -Business modules (knowledge.py, provenance.py, mcp/registry_tools.py, -run_tool.py) import the small public surface defined here. ``init_tracing`` installs -MLflow's own ``TracerProvider`` as the OTel global, so every -``trace.get_tracer(...)`` call routes spans into MLflow's native trace store -with full ``mlflow.spanInputs`` / ``mlflow.spanOutputs`` integration and -direct access to ``InMemoryTraceManager`` for trace-metadata stamping. - -Public surface --------------- -init_tracing(service_name, *, mlflow_url=None, session_id=None) - Configure MLflow's tracer provider once per process. Reads - ``./.dsagt/config.yaml`` for ``project`` and resolves the tracking URI - serverlessly (``resolve_tracking_uri``: ``MLFLOW_TRACKING_URI`` env → - config → default ``sqlite:////mlflow.db``). Never raises — when cwd - isn't a project dir it logs and no-ops, so one-shot tools / tests that - aren't in a project simply run without tracing. - -traced(span_name, *, capture=(), extract_return=None) - Decorator. Opens a span around a function call, captures named arguments - and (optionally) return-value-derived attributes, records exceptions, and - sets duration_ms. - -child_span(name, **attrs) - Context manager for nested spans inside a ``@traced`` method. - -obs - Process-wide proxy for the *current* span. ``obs.set("hits", 5)`` is a - no-op when no span is active, so business code can annotate without - branching on whether tracing is on. +DSAgt observability — first-party span emission over the serverless MLflow store. + +DSAGT writes every trace to one ``sqlite:////mlflow.db`` store (no server). +TWO emission paths share that one store; they differ because MLflow's API forces +it, not by accident: + + * LIVE tracer (``mlflow.start_span``) — first-party DSAGT spans emitted *as the + MCP server / dsagt-run runs*. Uses MLflow's active-span context for + auto-nesting and the ``obs`` proxy. Each trace's root is tagged + ``dsagt.source`` with the MCP tool *category* the agent invoked + (memory / skill / knowledge / registry), or ``execution`` for dsagt-run — + set at the dispatch boundary, so the UI can filter this *debugging* view + apart from agent traces and bucket it by concern. + * REPLAY sink (``MLflowSink`` → ``mlflow.start_span_no_context``) — a finished + agent ``traces.Trace`` backfilled after the fact with the transcript's + original timestamps. ``start_span`` cannot backdate (it has no + ``start_time_ns`` param), so replay *must* use ``start_span_no_context``; + live *should* use ``start_span`` (no_context establishes no active span, + which would kill the ``obs`` proxy and auto-nesting). Hence two paths, one + store — neither is a historical leftover. + +Layout (top → bottom) +--------------------- + setup find_project_config · resolve_tracking_uri · init_tracing + live tracer open_span ─┬─ traced (decorate a function) + ├─ child_span (open a sub-span) + └─ obs (annotate the span you're inside) + tagging: open_span(source=…) → _attach_trace_metadata + (dsagt.source set on the trace's root only) + factories: kb_* · registry_* · code_execute_span + replay sink MLflowSink (Trace → backdated spans; a traces.TraceCollector consumer) + +``traced`` and ``child_span`` *open* a span; ``obs`` *annotates* whichever span +is currently open. All three no-op when tracing was never initialized, so +business code never imports MLflow and never branches on whether tracing is on. """ from __future__ import annotations -import atexit import functools import inspect import logging -import os import time from contextlib import contextmanager from pathlib import Path @@ -46,36 +48,65 @@ logger = logging.getLogger(__name__) -# Module-level state. _initialized guards against double-init in test runs -# and in subprocesses where init_tracing might be called more than once. -# -# NOTE on _default_session_id: -# This is intentionally a process-global rather than threaded through -# every span helper. DSAgt runs one process per project, so the session -# id is a process-wide constant set at startup by init_tracing(), and -# propagating it implicitly via this module-level value lets business -# code in knowledge.py / provenance.py / mcp/registry_tools.py emit spans -# without ever knowing about session ids. The cost is that tests have -# to monkeypatch the global to isolate (see _reset_tracing fixture in -# test_observability.py). -# -# If DSAgt ever grows a multi-tenant mode (one server process serving -# many projects), the right replacement is OTel baggage: -# from opentelemetry import baggage -# sid = baggage.get_baggage("session.id") -# which gives per-request context propagation that works correctly -# across concurrent requests. Until then, the global is simpler. +# Module-level state. _initialized guards every span helper: without a tracking +# store + experiment set (init_tracing), mlflow.start_span would write to +# MLflow's default location, so the helpers no-op until init_tracing succeeds. +# DSAGT runs one process per project, so the session id is a process-wide +# constant set once at startup and tagged onto each internal trace for grouping. _initialized = False -_tracer_provider = None _default_session_id: str | None = None -# Strategy pointer bound at init_tracing time. Exists to keep -# backend-specific behavior out of call sites: -# -# _metadata_stamper(dict) -# Write key/value metadata to the currently-active trace via MLflow's -# InMemoryTraceManager (so it lands in trace_metadata, queryable in -# the UI). -_metadata_stamper: "Callable[[dict], None] | None" = None + + +# =========================================================================== +# Setup — where am I, where do I write, wire MLflow up +# =========================================================================== + + +def find_project_config() -> tuple[Path | None, dict | None]: + """Read ``./.dsagt/config.yaml`` from cwd. + + Returns ``(cwd, parsed_config)`` or ``(None, None)`` if cwd isn't a + project directory. No walking — services that need this info run + with cwd == project_dir by contract; if cwd is anywhere else the + caller is misconfigured and we fail fast. + + Project name → project_dir for arbitrary-cwd lookups (e.g., the + user CLI typing ``dsagt info ``) is the registry's job + (``~/dsagt-projects/projects.yaml``, see ``session.project_dir``). This + helper is only for services running inside the project. + """ + cwd = Path.cwd().resolve() + candidate = cwd / ".dsagt" / "config.yaml" + if not candidate.exists(): + return None, None + try: + import yaml + + return cwd, yaml.safe_load(candidate.read_text()) or {} + except Exception as e: + logger.debug("could not parse %s: %s", candidate, e) + return cwd, None + + +def resolve_tracking_uri(config: dict | None) -> str: + """Compute the serverless MLflow tracking URI for DSAGT self-logging. + + Always ``sqlite:////mlflow.db`` — DSAGT knows where it writes; + there is no server to point at. ``project_dir`` comes from the resolved + config (injected by ``session.load_config``), falling back to cwd for + in-project callers. + + The MLflow client honors a ``sqlite:`` URI directly (auto-creating + + migrating the DB on first use), so self-logging needs no listener and this + never has to fail. SQLite is MLflow's supported serverless backend — the + filesystem store (``file:`` / ``./mlruns``) is deprecated as of Feb 2026. + DSAGT emits only traces (no runs/models), so the experiment's default + artifact dir is never materialized. + """ + cfg = config or {} + pdir = cfg.get("project_dir") + base = Path(pdir).resolve() if pdir else Path.cwd().resolve() + return f"sqlite:///{base / 'mlflow.db'}" def init_tracing( @@ -83,27 +114,21 @@ def init_tracing( mlflow_url: str | None = None, session_id: str | None = None, ) -> None: - """Install MLflow's tracer provider as the OTel global. - - Every ``trace.get_tracer(...)`` call (from ``@traced`` / ``child_span`` - in MCP servers and dsagt-run) routes spans into MLflow's native trace - store. This gives ``mlflow.spanInputs`` / ``mlflow.spanOutputs`` - full integration and direct access to ``InMemoryTraceManager`` for - trace-metadata stamping (session id, source, agent). + """Point MLflow at the project's serverless store + experiment. - Serverless: reads ``./.dsagt/config.yaml`` for ``project`` and resolves - the tracking URI via :func:`resolve_tracking_uri` — defaulting to a - ``sqlite:////mlflow.db`` store that needs no running server. The session - id is passed in by the caller (the MCP server mints it at startup via - ``session.append_session``); ``None`` when not supplied. + After this, every ``mlflow.start_span`` (from ``@traced`` / ``child_span`` + in the MCP server and dsagt-run) lands in ``sqlite:////mlflow.db``. + The tracking URI is the serverless ``sqlite:////mlflow.db`` computed by + :func:`resolve_tracking_uri`. The session id, passed by the MCP server at + startup, tags internal traces for grouping. - The ``mlflow_url`` and ``session_id`` keyword args are kept for tests, - where the caller plants known values directly. + The ``mlflow_url`` and ``session_id`` keyword args are kept for tests, where + the caller plants known values directly. Never raises. When cwd isn't a dsagt project dir, logs and no-ops so one-shot tools / tests outside a project simply run untraced. """ - global _initialized, _default_session_id, _metadata_stamper + global _initialized, _default_session_id if _initialized: if session_id: @@ -127,10 +152,11 @@ def init_tracing( resolve_cfg["project_dir"] = str(cfg_pdir) mlflow_url = resolve_tracking_uri(resolve_cfg) - _install_mlflow_provider(mlflow_url, project_name) - _metadata_stamper = _stamp_metadata_mlflow + import mlflow + + mlflow.set_tracking_uri(mlflow_url) + mlflow.set_experiment(project_name) _initialized = True - atexit.register(_shutdown) logger.info( "init_tracing: service=%s mlflow=%s project=%s session=%s", service_name, @@ -140,179 +166,121 @@ def init_tracing( ) -def find_project_config() -> tuple[Path | None, dict | None]: - """Read ``./.dsagt/config.yaml`` from cwd. - - Returns ``(cwd, parsed_config)`` or ``(None, None)`` if cwd isn't a - project directory. No walking — services that need this info run - with cwd == project_dir by contract; if cwd is anywhere else the - caller is misconfigured and we fail fast. - - Project name → project_dir for arbitrary-cwd lookups (e.g., the - user CLI typing ``dsagt info ``) is the registry's job - (``~/dsagt-projects/projects.yaml``, see ``session.project_dir``). This - helper is only for services running inside the project. - """ - cwd = Path.cwd().resolve() - candidate = cwd / ".dsagt" / "config.yaml" - if not candidate.exists(): - return None, None - try: - import yaml +# =========================================================================== +# Live tracer — first-party debug spans, emitted as code runs +# =========================================================================== - return cwd, yaml.safe_load(candidate.read_text()) or {} - except Exception as e: - logger.debug("could not parse %s: %s", candidate, e) - return cwd, None +# ----- the span primitive ----- -def resolve_tracking_uri(config: dict | None) -> str: - """Resolve the MLflow tracking URI for DSAGT self-logging. Never raises. - - Resolution order: - 1. ``MLFLOW_TRACKING_URI`` env — join the user's own store if they - run one. - 2. ``mlflow.tracking_uri`` in the project config — an explicit - override. - 3. Default ``sqlite:////mlflow.db`` — a serverless - SQLite store that always works with no listener running. - - The MLflow Python client honors a ``sqlite:`` tracking URI directly - (auto-creating + migrating the DB on first use), so self-logging needs - no server and the resolver never has to fail. SQLite is MLflow's - supported serverless backend — the filesystem store (``file:`` / - ``./mlruns``) is deprecated as of Feb 2026. DSAGT emits only traces - (no runs/models), so the experiment's default artifact dir is never - materialized. +@contextmanager +def open_span(name: str, span_type: str | None = None, source: str | None = None): + """Open a span on the serverless MLflow store. + + Single tracing code path — ``mlflow.start_span`` auto-nests under whatever + span is already active (its own context model), so child spans Just Work. + Yields ``None`` when tracing was never initialized (one-shot tools / tests + outside a project), so the helpers below degrade to no-ops. + + ``source`` is set only on the *categorization root* of a trace — the MCP + dispatch span and ``tool.execute`` — and tags the whole trace + ``dsagt.source`` for the debug-view filter (see :func:`_attach_trace_metadata`). + Inner spans pass ``source=None`` and inherit the root's tag. """ - env_uri = os.environ.get("MLFLOW_TRACKING_URI") - if env_uri: - return env_uri - cfg = config or {} - configured = (cfg.get("mlflow") or {}).get("tracking_uri") - if configured: - return configured - pdir = cfg.get("project_dir") - base = Path(pdir).resolve() if pdir else Path.cwd().resolve() - return f"sqlite:///{base / 'mlflow.db'}" + if not _initialized: + yield None + return + import mlflow + from mlflow.entities import SpanType + with mlflow.start_span(name=name, span_type=span_type or SpanType.UNKNOWN) as span: + _attach_trace_metadata(source) + yield span -def _install_mlflow_provider(mlflow_url: str, project_name: str) -> None: - """Wire MLflow's tracer provider in as the OTel global. - Force MLflow's lazy provider to initialize via its private init hook - so we can hand the resulting TracerProvider to OTel below. The - underscore on the init function is MLflow-internal — pinning the - mlflow version range in pyproject.toml keeps that boundary stable. - """ - import mlflow - from mlflow.tracing import provider as mp - from opentelemetry import trace - from opentelemetry.util._once import Once +# ----- attribute-value helpers ----- - mlflow.set_tracking_uri(mlflow_url) - mlflow.set_experiment(project_name) - - mp._initialize_tracer_provider() - # OTel guards set_tracer_provider with a one-shot Once flag — the first - # caller wins. Reset so installation always takes effect even if - # something accessed get_tracer earlier and locked in the no-op global. - trace._TRACER_PROVIDER = None # type: ignore[attr-defined] - trace._TRACER_PROVIDER_SET_ONCE = Once() # type: ignore[attr-defined] - trace.set_tracer_provider(mp.provider.get()) +def _coerce_attr(value: Any) -> Any: + if isinstance(value, (str, bool, int, float)): + return value + if isinstance(value, (list, tuple)): + return [_coerce_attr(v) for v in value] + return str(value) -def _stamp_metadata_on_trace(request_id: str, metadata: dict) -> None: - """Write metadata to a specific MLflow trace by id. +def truncate(value: str, limit: int = 256) -> str: + """Truncate a string for span attributes. - Used when the caller has the trace_id in hand. ``stamp_metadata`` is - the higher-level version that looks up the current trace via the - active OTel span. + Span backends (and the MLflow trace UI) handle short attribute values + much better than multi-megabyte stdout/stderr blobs. The full payload + lives in ``trace_archive/.json``; the span just carries a + head/tail summary so a human glancing at the UI can tell what happened. """ - try: - from mlflow.tracing.trace_manager import InMemoryTraceManager + if value is None: + return "" + if len(value) <= limit: + return value + head = limit - 32 + return value[:head] + f"... [+{len(value) - head} chars]" - with InMemoryTraceManager.get_instance().get_trace(request_id) as t: - if t is not None: - t.info.trace_metadata.update({k: str(v) for k, v in metadata.items()}) - except Exception as e: - logger.debug("metadata stamp failed for %s: %s", request_id, e) +# ----- trace tagging — the dsagt.source debug filter + session grouping ----- +# +# dsagt.source names the MCP tool *category* that was invoked — one of +# {memory, skill, knowledge, registry} (the four concern modules of the merged +# dsagt-server), plus ``execution`` for dsagt-run's out-of-process data-tool +# runs. It is assigned at the *entry point*, not derived from the span name: +# the MCP dispatch shell knows which concern owns each tool and stamps the +# category on the trace's root span, so e.g. ``search_skills`` calling into +# ``kb.search`` is tagged ``skill`` (the tool the agent called), not +# ``knowledge`` (the subsystem that happened to do the work). Inner spans +# inherit the root's tag; agent traces carry no ``dsagt.source`` at all, which +# is what lets the MLflow UI filter the debug view in or out. -def _stamp_metadata_mlflow(metadata: dict) -> None: - """Write metadata to the currently-active trace's trace_metadata.""" - from opentelemetry import trace - span = trace.get_current_span() - ctx = span.get_span_context() - if not ctx.is_valid: - return - _stamp_metadata_on_trace(f"tr-{ctx.trace_id:032x}", metadata) +def _attach_trace_metadata(source: str | None) -> None: + """Tag the current trace as a DSAGT-internal (debug) trace. + Called from :func:`open_span` on a categorization root (``source`` set): -def stamp_metadata(metadata: dict) -> None: - """Stamp arbitrary key/value metadata on the currently-active trace. + - ``dsagt.source`` tag: the MCP tool category / ``execution`` — powers the + UI's debug-view filter. + - ``mlflow.trace.session`` metadata: groups this process's internal traces + under one session (reserved MLflow key, drives the native session filter). - No-op when no backend is configured (tests, standalone tools). + No-op for inner spans (``source is None``) — they inherit the root's tag. """ - if _metadata_stamper is not None and metadata: - _metadata_stamper(metadata) - - -def _shutdown() -> None: - """Flush and shut down the tracer provider. Registered via atexit.""" - global _tracer_provider - if _tracer_provider is None: + if not source: return - try: - _tracer_provider.shutdown() - except Exception as e: - logger.debug("tracer shutdown raised: %s", e) - _tracer_provider = None - - -def get_tracer(name: str): - """Return an OTel tracer routed through the OTLP provider installed by - ``init_tracing``.""" - from opentelemetry import trace - - return trace.get_tracer(name) - + metadata = ( + {"mlflow.trace.session": _default_session_id} if _default_session_id else None + ) + import mlflow -@contextmanager -def open_span(name: str): - """Open a span on the installed tracer provider. + mlflow.update_current_trace(tags={"dsagt.source": source}, metadata=metadata) - Single OTel code path — provider selection happens once at - ``init_tracing`` time. Yields ``None`` if tracing was never initialized - (test paths that skip init_tracing monkeypatch module state directly). - """ - if not _initialized: - yield None - return - tracer = get_tracer("dsagt.observability") - with tracer.start_as_current_span(name) as span: - yield span +# ----- annotate the active span ----- -# --------------------------------------------------------------------------- -# obs — process-wide proxy for the current span -# --------------------------------------------------------------------------- +class _ActiveSpanProxy: + """The *annotate* verb that complements the *open* verbs (traced/child_span). -class _Obs: - """No-op-safe proxy for the active span. + Where ``traced`` / ``child_span`` open a span, this annotates whichever span + is currently open — ``obs.set("hits", 5)`` from inside a ``@traced`` body + attaches to that body's span. It exists so business code (knowledge.py, + provenance.py, registry_tools.py) never imports MLflow and never branches on + whether tracing is on: when no span is active (tracing disabled, or call + site outside any traced block) every method silently does nothing. - Business code uses ``obs.set("hits", 5)`` instead of importing OTel. When - no span is active (tracing disabled, or call site outside any traced - block), every method silently does nothing. + The process-wide singleton is exported as ``obs``. """ def set(self, key: str, value: Any) -> None: span = self._current() if span is not None and value is not None: - span.set_attribute(key, value) + span.set_attribute(key, _coerce_attr(value)) def set_many(self, attrs: Mapping[str, Any]) -> None: span = self._current() @@ -320,53 +288,45 @@ def set_many(self, attrs: Mapping[str, Any]) -> None: return for k, v in attrs.items(): if v is not None: - span.set_attribute(k, v) + span.set_attribute(k, _coerce_attr(v)) def event(self, name: str, **attrs: Any) -> None: span = self._current() if span is None: return + from mlflow.entities import SpanEvent + clean_attrs = {k: v for k, v in attrs.items() if v is not None} - span.add_event(name, attributes=clean_attrs) + span.add_event(SpanEvent(name, attributes=clean_attrs)) def set_inputs(self, inputs: Any) -> None: - """Populate the trace's ``request`` field for the MLflow trace UI. - - Stamps ``mlflow.spanInputs`` as a JSON-serialized OTel attribute — - this is the same key MLflow's ``LiveSpan.set_inputs`` writes, so it - flows through to MLflow's ``request`` column in ``search_traces``. - For non-MLflow OTel backends (Jaeger, Tempo) it just shows up as a - regular span attribute, harmlessly. - """ + """Populate the trace's ``request`` field for the MLflow trace UI.""" span = self._current() if span is None or inputs is None: return - span.set_attribute("mlflow.spanInputs", _to_json(inputs)) + span.set_inputs(inputs) def set_outputs(self, outputs: Any) -> None: """Populate the trace's ``response`` field for the MLflow trace UI.""" span = self._current() if span is None or outputs is None: return - span.set_attribute("mlflow.spanOutputs", _to_json(outputs)) + span.set_outputs(outputs) @staticmethod def _current(): - """Return the currently-active OTel span, or ``None`` if none.""" - from opentelemetry import trace - - span = trace.get_current_span() - if span is None or not span.get_span_context().is_valid: + """Return the currently-active MLflow span, or ``None`` if none.""" + if not _initialized: return None - return span + import mlflow + + return mlflow.get_current_active_span() -obs = _Obs() +obs = _ActiveSpanProxy() -# --------------------------------------------------------------------------- -# traced — decorator for top-level method instrumentation -# --------------------------------------------------------------------------- +# ----- open a span — decorator + context manager ----- def traced( @@ -375,7 +335,7 @@ def traced( capture: Iterable[str] = (), extract_return: Mapping[str, Callable[[Any], Any]] | None = None, ) -> Callable: - """Wrap a function in an OTel span. + """Wrap a function in an MLflow span. Parameters ---------- @@ -387,16 +347,15 @@ def traced( work. extract_return Optional mapping of attribute name → function applied to the return - value to extract that attribute. Use this for things like - ``{"hits": lambda r: len(r)}``. + value to extract that attribute (e.g. ``{"hits": lambda r: len(r)}``). Behavior -------- * Captures the configured args as attributes (skipping ``None``). * Always sets ``duration_ms``. - * Always sets the DSAgt session id (if init_tracing was given one). - * Records exceptions via ``span.record_exception`` and sets ERROR status. - * Re-raises after recording. + * Tags the trace ``dsagt.source`` + session for the debug view. + * Records exceptions and sets ERROR status (MLflow auto-records the + exception on context exit); re-raises after recording. """ capture = tuple(capture) extract_return = dict(extract_return or {}) @@ -409,7 +368,6 @@ def wrapper(*args, **kwargs): with open_span(span_name) as span: if span is None: return fn(*args, **kwargs) - _attach_trace_metadata(span_name) # Capture configured arguments as span attributes. Looked up # by name against the function signature so positional and @@ -432,9 +390,8 @@ def wrapper(*args, **kwargs): start = time.perf_counter() try: result = fn(*args, **kwargs) - except Exception as exc: - span.record_exception(exc) - _set_error_status(span, str(exc)) + except Exception: + span.set_status("ERROR") raise finally: span.set_attribute( @@ -468,111 +425,31 @@ def wrapper(*args, **kwargs): return decorator -_SOURCE_BY_PREFIX = ( - ("tool.", "tool"), - ("kb.", "knowledge"), - ("registry.", "registry"), -) - - -def _derive_source(span_name: str | None) -> str | None: - """Map a span name to a ``dsagt.source`` value. - - Prefix-based dispatch so every span we open lands with a source tag - without touching the call site. Covers the non-LLM spans our own - instrumentation emits (kb / registry / tool). - """ - if not span_name: - return None - for prefix, source in _SOURCE_BY_PREFIX: - if span_name.startswith(prefix): - return source - return None - - -def _attach_trace_metadata(span_name: str | None) -> None: - """Stamp session + source on the currently-active trace. - - - ``mlflow.trace.session``: process-wide session id (reserved MLflow - key; powers the UI's native session filter). - - ``dsagt.source``: derived from the span name prefix — so every span - we open lands in ``dsagt info``'s "by source" bucket. - - No-op when no backend is configured or no span is active; the - metadata stamper handles both cases. - """ - md: dict[str, str] = {} - if _default_session_id: - md["mlflow.trace.session"] = _default_session_id - src = _derive_source(span_name) - if src: - md["dsagt.source"] = src - if md: - stamp_metadata(md) - - -def _to_json(value: Any) -> str: - """Serialize *value* to JSON for an OTel attribute. - - OTel attributes only accept primitives + sequences of primitives; - MLflow's ``mlflow.spanInputs`` / ``mlflow.spanOutputs`` keys expect - a JSON-encoded payload that the trace UI deserializes for display. - """ - import json - - try: - return json.dumps(value, default=str) - except (TypeError, ValueError): - return str(value) - - -def _coerce_attr(value: Any) -> Any: - if isinstance(value, (str, bool, int, float)): - return value - if isinstance(value, (list, tuple)): - return [_coerce_attr(v) for v in value] - return str(value) - - -def _set_error_status(span, message: str) -> None: - # OTel is mandatory; if Status/StatusCode disappears or moves, we want - # the resulting ImportError to surface immediately so the dep upgrade - # gets caught at the test level rather than silently disabling error - # status on every traced exception path. - from opentelemetry.trace import Status, StatusCode - - span.set_status(Status(StatusCode.ERROR, message)) - - -# --------------------------------------------------------------------------- -# Typed span helpers — populated incrementally as later stages need them. -# -# The convention is: every span name DSAgt emits has a helper here. Business -# modules call the helper, never tracer.start_as_current_span directly. This -# keeps span names, attribute schemas, and required fields in one file. -# --------------------------------------------------------------------------- - - @contextmanager -def child_span(name: str, **attrs: Any): +def child_span(name: str, *, span_type: str | None = None, **attrs: Any): """Open a child span with arbitrary attributes. - Use this from inside a ``@traced`` method when you need to break a method - into sub-phases (e.g. embed / index_search / rerank inside kb.search). - Prefer the typed helpers below when one exists for your operation. + Use this from inside a ``@traced`` method to break a method into sub-phases + (e.g. embed / index_search / rerank inside kb.search). Prefer the typed + factories below when one exists for your operation. """ - with open_span(name) as span: + with open_span(name, span_type=span_type) as span: if span is None: yield None return - _attach_trace_metadata(name) for k, v in attrs.items(): if v is not None: span.set_attribute(k, _coerce_attr(v)) yield span -# ----- Knowledge base spans (Stage 1) ----- +# ----- named span factories ----- +# +# Every span name DSAgt emits has a factory here. Business modules call the +# factory, never mlflow.start_span directly, so span names, attribute schemas, +# and span types stay in one file. + +# Knowledge base spans. def kb_embed_span(backend: str | None, model: str | None, n_texts: int): @@ -582,8 +459,11 @@ def kb_embed_span(backend: str | None, model: str | None, n_texts: int): kb.append, kb.add_entries). Backend-agnostic: ``backend`` is ``"api"`` for the HTTP embedder or ``"local"`` for sentence-transformers. """ + from mlflow.entities import SpanType + return child_span( "kb.embed", + span_type=SpanType.EMBEDDING, backend=backend, model=model, n_texts=n_texts, @@ -592,8 +472,11 @@ def kb_embed_span(backend: str | None, model: str | None, n_texts: int): def kb_index_search_span(vector_db: str | None, k: int, filtered: bool): """Span around an underlying vector index search call.""" + from mlflow.entities import SpanType + return child_span( "kb.index_search", + span_type=SpanType.RETRIEVER, vector_db=vector_db, k=k, filtered=filtered, @@ -609,23 +492,28 @@ def kb_rerank_span(model: str | None, n_pairs: int): ) -# ----- Registry server spans (Stage 4) ----- -# -# Only the deliberate, infrequent registry operations are instrumented. -# search_registry / search_skills are intentionally NOT instrumented — they +# Registry spans. Only the deliberate, infrequent registry operations are +# instrumented. search_registry / search_skills are intentionally NOT — they # are high-frequency low-information per call, and the agent-side LLM trace # already records that they were invoked. -def registry_save_tool_span(tool_name: str | None): - """Span around ``save_tool_spec``.""" - return child_span("registry.save_tool_spec", tool_name=tool_name) +def registry_save_code_span(code_name: str | None): + """Span around ``save_code_spec``.""" + from mlflow.entities import SpanType + + return child_span( + "registry.save_code_spec", span_type=SpanType.TOOL, code_name=code_name + ) def registry_install_deps_span(packages: list[str] | None): """Span around an ``install_dependencies`` call.""" + from mlflow.entities import SpanType + return child_span( "registry.install_dependencies", + span_type=SpanType.TOOL, package_count=len(packages) if packages else 0, # First few package names are useful in the UI for at-a-glance # identification; full list is in the LLM call record if needed. @@ -635,64 +523,46 @@ def registry_install_deps_span(packages: list[str] | None): def registry_reconstruct_pipeline_span(fmt: str | None): """Span around a ``reconstruct_pipeline`` call.""" - return child_span("registry.reconstruct_pipeline", format=fmt or "bash") + from mlflow.entities import SpanType - -# ----- Tool execution spans (Stage 3) ----- + return child_span( + "registry.reconstruct_pipeline", span_type=SpanType.TOOL, format=fmt or "bash" + ) -def tool_execute_span(record_id: str, tool_name: str): - """Span around a single ``dsagt-run`` tool execution. +# Code execution spans (dsagt-run). - This is a *top-level* span, not a child of any LLM call span. The agent - CLI (Claude Code, Goose, etc.) spawns ``dsagt-run`` in its own process - tree without OTel context propagation, so the LLM-call-to-tool-execution - parent/child linkage cannot be expressed in the trace tree directly. - Instead, every ``tool.execute`` span carries: - * ``session.id`` (attached automatically via init_tracing's session_id) - * ``record_id`` — correlates the execution to its ``trace_archive`` record. - * ``tool_name`` — for filtering in the MLflow UI. +def code_execute_span(record_id: str, code_name: str): + """Span around a single ``dsagt-run`` code execution. - Filter by ``session.id`` in the MLflow trace view to see all tool - executions for a given project alongside the LLM calls that requested - them. Cross-reference via ``record_id`` for full intent → execution - linkage when needed. + A *top-level, categorization-root* span: the agent CLI spawns ``dsagt-run`` + in its own process tree, so this trace stands alone in the store rather than + nesting under any MCP-dispatch span. It carries ``record_id`` (correlates + to the ``trace_archive`` record) and ``code_name``, and is tagged + ``dsagt.source=execution`` — its own bucket, distinct from the four MCP tool + categories, since these are actual code runs rather than meta-ops. """ + from mlflow.entities import SpanType @contextmanager def _wrapper(): - with open_span("tool.execute") as span: + with open_span( + "code.execute", span_type=SpanType.TOOL, source="execution" + ) as span: if span is None: yield None return - _attach_trace_metadata("tool.execute") span.set_attribute("record_id", record_id) - span.set_attribute("tool_name", tool_name) + span.set_attribute("code_name", code_name) yield span return _wrapper() -def truncate(value: str, limit: int = 256) -> str: - """Truncate a string for span attributes. - - Span backends (and the MLflow trace UI) handle short attribute values - much better than multi-megabyte stdout/stderr blobs. The full payload - lives in ``trace_archive/.json``; the span just carries a - head/tail summary so a human glancing at the UI can tell what happened. - """ - if value is None: - return "" - if len(value) <= limit: - return value - head = limit - 32 - return value[:head] + f"... [+{len(value) - head} chars]" - - -# --------------------------------------------------------------------------- -# Agent-trace sink — CanonicalTrace → MLflow spans (the Phase-2 foreign feed) -# --------------------------------------------------------------------------- +# =========================================================================== +# Replay sink — finished agent Trace → backdated MLflow spans +# =========================================================================== _S_PER_NS = 1e9 @@ -704,15 +574,16 @@ def _to_ns(epoch_s: float | None) -> int | None: class MLflowSink: """Render a :class:`~dsagt.traces.Trace` into MLflow spans (a trace consumer). - The agent (foreign-trace) half of observability: where ``init_tracing`` + - ``@traced`` emit DSAGT's own first-party spans live, this replays a finished - transcript's :class:`~dsagt.traces.Trace` after the fact. It uses + The agent half of observability: where ``@traced`` / ``obs`` emit DSAGT's + own first-party debug spans live, this replays a finished transcript's + :class:`~dsagt.traces.Trace` after the fact into the *same* store. It uses ``mlflow.start_span_no_context`` — the only API that accepts an explicit ``parent_span`` and backdated ``start_time_ns`` — and mirrors the span conventions of MLflow's own ``claude_code`` autolog so foreign traces render identically in the Chat UI: an AGENT root, ``llm`` children carrying ``message.format="anthropic"`` + ``mlflow.chat.tokenUsage``, and - ``tool_`` children. + ``tool_`` children. Agent traces carry no ``dsagt.source`` tag, so + they stay in the normal view, separate from the internal debug traces. A session ``Trace`` carries one AGENT subtree per turn; the sink emits **one MLflow trace per AGENT root**, matching the per-prompt granularity autolog's diff --git a/src/dsagt/provenance.py b/src/dsagt/provenance.py index 38b9798..5e58ea9 100644 --- a/src/dsagt/provenance.py +++ b/src/dsagt/provenance.py @@ -1,13 +1,13 @@ """ -Provenance for tool executions. +Provenance for code executions. **Execution capture** (dsagt-run wrapper): Wraps a shell command, captures exact execution data (command, exit code, stdout/stderr, input/output files), and writes a JSON record - to ``trace_archive/__.json``. + to ``trace_archive/__.json``. **Record indexing** (ChromaDB): - Indexes execution records into a ``tool_executions`` collection for + Indexes execution records into a ``code_use`` collection for semantic search and metadata filtering. **Pipeline reconstruction**: @@ -39,13 +39,8 @@ logger = logging.getLogger(__name__) -#: Project-local collection of indexed tool-execution records. -#: Renamed from ``tool_executions`` to match the user-facing -#: terminology ("tool_use index"). -TOOL_USE_COLLECTION = "tool_use" - -#: Backwards-compat alias. New code should use ``TOOL_USE_COLLECTION``. -TOOL_EXECUTIONS_COLLECTION = TOOL_USE_COLLECTION +#: Project-local collection of indexed code-execution records. +CODE_USE_COLLECTION = "code_use" # --------------------------------------------------------------------------- @@ -99,7 +94,7 @@ def _parse_file_list(raw: str | None) -> list[str]: def run_and_record( - tool_name: str, + code_name: str, command: list[str], records_dir: Path, session_id: str | None = None, @@ -108,17 +103,17 @@ def run_and_record( output_files: list[str] | None = None, ) -> int: """Execute a command, write an execution record, return the exit code.""" - from dsagt.observability import obs, tool_execute_span, truncate + from dsagt.observability import obs, code_execute_span, truncate record_id = record_id or uuid.uuid4().hex[:12] if session_id is None: # The MCP server mints the session at startup and records it in # ``.dsagt/state.yaml``; read the current tag from there so this - # tool span buckets with the rest of the session (cwd == project + # code span buckets with the rest of the session (cwd == project # dir by contract). ``None`` if no session has been minted yet. session_id = _current_session_tag_from_cwd() - with tool_execute_span(record_id, tool_name): + with code_execute_span(record_id, code_name): timestamp_start = datetime.now(timezone.utc).isoformat() start_perf = time.perf_counter() @@ -160,14 +155,14 @@ def run_and_record( if stderr.strip(): obs.set("stderr_truncated", truncate(stderr, 256)) if return_code != 0: - obs.event("tool_failed", exit_code=return_code) + obs.event("code_failed", exit_code=return_code) # Populate the MLflow trace UI's Input/Output tabs. Truncate to - # ~4KB per side so big tool results don't bloat the trace store + # ~4KB per side so big code results don't bloat the trace store # (the full payload is on disk in trace_archive/.json). obs.set_inputs( { - "tool": tool_name, + "code": code_name, "command": list(command), "input_files": input_files or [], } @@ -184,7 +179,7 @@ def run_and_record( record = { "record_id": record_id, - "tool_name": tool_name, + "code_name": code_name, "session_id": session_id, "execution": { "exact_command": command, @@ -213,7 +208,7 @@ def _write_record(record: dict, records_dir: Path) -> Path: records_dir.mkdir(parents=True, exist_ok=True) ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") - filename = f"{record['tool_name']}_{ts}_{record['record_id']}.json" + filename = f"{record['code_name']}_{ts}_{record['record_id']}.json" path = records_dir / filename path.write_text(json.dumps(record, indent=2, ensure_ascii=False) + "\n") @@ -226,31 +221,22 @@ def _write_record(record: dict, records_dir: Path) -> Path: def render_execution_text(record: dict) -> str: - """Convert a tool execution record into embeddable natural-language text.""" - tool_name = record.get("tool_name", "unknown") - intent = record.get("intent") or {} + """Convert a code execution record into embeddable natural-language text.""" + code_name = record.get("code_name", "unknown") execution = record.get("execution") - report = record.get("report") or {} - parts = [f"Tool: {tool_name}"] + parts = [f"Code: {code_name}"] if execution and execution.get("exact_command"): cmd = execution["exact_command"] if isinstance(cmd, list): cmd = " ".join(cmd) parts.append(f"Command: {cmd}") - elif intent.get("parameters"): - parts.append(f"Parameters: {json.dumps(intent['parameters'])}") if execution and execution.get("return_code") is not None: rc = execution["return_code"] status = "succeeded" if rc == 0 else f"failed (exit code {rc})" parts.append(f"Outcome: {status}") - elif report.get("agent_output"): - output = report["agent_output"] - if len(output) > 500: - output = output[:500] + "..." - parts.append(f"Agent report: {output}") if execution: start = execution.get("timestamp_start", "") @@ -274,26 +260,18 @@ def render_execution_text(record: dict) -> str: def execution_metadata(record: dict) -> dict: - """Extract ChromaDB-filterable metadata from a tool execution record.""" + """Extract ChromaDB-filterable metadata from a code execution record.""" execution = record.get("execution") - intent = record.get("intent") or {} meta: dict = {} - meta["tool_name"] = record.get("tool_name", intent.get("command", "unknown")) - meta["session_id"] = record.get("session_id", intent.get("session_id", "unknown")) + meta["code_name"] = record.get("code_name", "unknown") + meta["session_id"] = record.get("session_id", "unknown") if execution and execution.get("return_code") is not None: meta["return_code"] = execution["return_code"] - meta["wrapper_used"] = 1 if execution is not None else 0 - - timestamp = None if execution and execution.get("timestamp_start"): - timestamp = execution["timestamp_start"] - elif intent.get("timestamp_requested"): - timestamp = intent["timestamp_requested"] - if timestamp: - meta["timestamp"] = timestamp + meta["timestamp"] = execution["timestamp_start"] record_id = record.get("record_id", "") if record_id: @@ -307,7 +285,7 @@ def index_trace_archive( kb: KnowledgeBase, indexed_ids: set[str] | None = None, ) -> dict: - """Batch-index all tool execution records in a trace archive directory.""" + """Batch-index all code execution records in a trace archive directory.""" if indexed_ids is None: indexed_ids = set() @@ -332,10 +310,8 @@ def index_trace_archive( skipped += 1 continue - has_intent = "intent" in record - has_execution = record.get("execution") is not None - if not has_intent and not has_execution: - logger.warning("Skipping %s: no intent or execution layer", path.name) + if record.get("execution") is None: + logger.warning("Skipping %s: no execution layer", path.name) errors += 1 continue @@ -349,7 +325,7 @@ def index_trace_archive( if texts: kb.add_entries( texts=texts, - collection=TOOL_EXECUTIONS_COLLECTION, + collection=CODE_USE_COLLECTION, metadatas=metadatas, ) @@ -361,10 +337,10 @@ def index_trace_archive( } -class ToolUseIndexer: - """Idempotent, incremental indexer of ``dsagt-run`` records into ``tool_use``. +class CodeUseIndexer: + """Idempotent, incremental indexer of ``dsagt-run`` records into ``code_use``. - The tool-execution counterpart to :class:`~dsagt.trace_scan.TraceScan`: + The code-execution counterpart to :class:`~dsagt.trace_scan.TraceScan`: ``dsagt-run`` writes one JSON record per call to ``trace_archive/``, and each :meth:`tick` embeds only the records not already indexed — tracked by ``record_id`` in a persisted ack set — so re-ticks and cross-session @@ -372,7 +348,7 @@ class ToolUseIndexer: One primitive, three triggers, all safe to overlap: the MCP-server heartbeat (current-session freshness), startup catch-up (the previous session's tail), - and the ``reconstruct_pipeline`` tool (index-then-reconstruct, so a pipeline + and the ``reconstruct_pipeline`` code (index-then-reconstruct, so a pipeline review reflects the calls just made). An OS file lock around load→index→save serializes those callers — distinct instances in one process, or a future cross-process ticker — against the shared ack file. @@ -382,7 +358,7 @@ def __init__(self, kb: KnowledgeBase, project_dir: str | Path): self._kb = kb pdir = Path(project_dir) self._trace_dir = pdir / "trace_archive" - self._acks_path = pdir / ".dsagt" / "tool_use_acks.json" + self._acks_path = pdir / ".dsagt" / "code_use_acks.json" def _load_acks(self) -> set[str]: try: @@ -474,20 +450,20 @@ def render_bash(records: list[dict], deps: dict[int, list[int]]) -> str: ] for i, record in enumerate(records): - tool = record["tool_name"] + code = record["code_name"] execution = record["execution"] cmd = execution["exact_command"] rc = execution.get("return_code", 0) inputs = execution.get("input_files", []) outputs = execution.get("output_files", []) - lines.append(f"# Step {i + 1}: {tool}") + lines.append(f"# Step {i + 1}: {code}") if inputs: lines.append(f"# inputs: {', '.join(inputs)}") if outputs: lines.append(f"# outputs: {', '.join(outputs)}") if deps[i]: - dep_names = [records[d]["tool_name"] for d in deps[i]] + dep_names = [records[d]["code_name"] for d in deps[i]] lines.append(f"# depends: {', '.join(dep_names)}") if rc != 0: lines.append(f"# WARNING: original run exited with code {rc}") @@ -508,8 +484,8 @@ def render_snakemake(records: list[dict], deps: dict[int, list[int]]) -> str: rule_names = [] for i, record in enumerate(records): - tool = record["tool_name"] - rule_name = f"{tool}_{i + 1}" + code = record["code_name"] + rule_name = f"{code}_{i + 1}" rule_names.append(rule_name) all_outputs = [] diff --git a/src/dsagt/registry.py b/src/dsagt/registry.py index e7f7aad..f0bfb84 100644 --- a/src/dsagt/registry.py +++ b/src/dsagt/registry.py @@ -1,11 +1,11 @@ """ -Tool and Skill Registries. +Code and Skill Registries. Two parallel registries for agent capabilities: -**Tools** (CLI executables) — markdown files with YAML frontmatter specifying +**Codes** (CLI executables) — markdown files with YAML frontmatter specifying name, description, executable, parameters, dependencies, tags. Stored in -`/tools/`. Agent-written scripts go in `/tools/code/`. +`/codes/`. Agent-written scripts go in `/codes/scripts/`. When registered, executables are wrapped with dsagt-run + uv run --with. **Skills** (agent instructions) — directories containing a SKILL.md with @@ -14,7 +14,7 @@ workflow instructions. Both registries support optional KB indexing for semantic search via -`search_registry` (tools) and `search_skills` (skills) MCP tools. +`search_registry` (codes) and `search_skills` (skills) MCP tools. """ from __future__ import annotations @@ -35,11 +35,11 @@ logger = logging.getLogger(__name__) #: Single project-local collection holding both bundled (package-shipped) -#: and registered (agent-saved) tools. Bundled entries carry +#: and registered (agent-saved) codes. Bundled entries carry #: ``metadata.source = "bundled"`` and ``metadata.dsagt_version`` so #: they can be evicted and refreshed on dsagt upgrade without touching #: agent-registered entries. -TOOLS_COLLECTION = "tools" +CODES_COLLECTION = "codes" #: Legacy installed-skills collection. No longer written or read: installed #: skills are natively auto-discovered by every supported agent, so skill #: search covers only the *catalog* tier below. Kept as a name for back-compat @@ -61,12 +61,12 @@ def catalog_collection(slug: str) -> str: #: Backwards-compat aliases — kept so external code that imported the #: previous names still resolves. New code should use the names above. -TOOL_REGISTRY_COLLECTION = TOOLS_COLLECTION +TOOL_REGISTRY_COLLECTION = CODES_COLLECTION SKILL_REGISTRY_COLLECTION = SKILLS_COLLECTION # --------------------------------------------------------------------------- -# Helpers (tools only) +# Helpers (codes only) # --------------------------------------------------------------------------- @@ -80,16 +80,16 @@ def _uv_run_prefix(deps: list[str]) -> str: def _wrap_executable(name: str, executable: str, deps: list[str] | None = None) -> str: """Wrap an executable with uv run (for Python deps) and dsagt-run (for provenance). - Result: dsagt-run --tool -- [uv run --with deps --] + Result: dsagt-run --code -- [uv run --with deps --] """ if "dsagt-run" in executable: return executable inner = f"{_uv_run_prefix(deps or [])}{executable}" - return f"dsagt-run --tool {name} -- {inner}" + return f"dsagt-run --code {name} -- {inner}" -def _generate_tool_body(spec: dict) -> str: - """Generate a markdown body for a new tool file from its spec.""" +def _generate_code_body(spec: dict) -> str: + """Generate a markdown body for a new code file from its spec.""" lines = [ f"\n# {spec['name']}\n\n{spec['description']}\n\n", "## Shell Command\n\n```bash\n", @@ -119,7 +119,7 @@ def _parse_frontmatter(path: Path) -> dict: mapping. Rather than silently dropping such skills from discovery, fall back to a best-effort flat parse (:func:`_lenient_frontmatter`) on YAML error so ``name`` / ``description`` / ``tags`` are still recovered. dsagt-authored - tool/skill specs are valid YAML, so the fallback never fires for them. + code/skill specs are valid YAML, so the fallback never fires for them. """ text = path.read_text() if not text.startswith("---"): @@ -186,7 +186,7 @@ def _lenient_frontmatter(block: str) -> dict: # CLI rendering # --------------------------------------------------------------------------- # -# Each parameter in a tool spec may declare a `cli` field that pins how its +# Each parameter in a code spec may declare a `cli` field that pins how its # value should be placed on the command line. Supported forms: # # positional — first positional slot @@ -272,32 +272,32 @@ def render_arguments(parameters: dict, values: dict) -> list[str]: # --------------------------------------------------------------------------- -# Tool Registry +# Code Registry # --------------------------------------------------------------------------- -class ToolRegistry: +class CodeRegistry: """ - Manages CLI tool spec files and optional KB indexing. + Manages CLI code spec files and optional KB indexing. Two layers: - * **Bundled tools** ship with the dsagt package at - ``_PACKAGE_TOOLS_DIR``. They are read-only; their KB embeddings + * **Bundled codes** ship with the dsagt package at + ``_PACKAGE_CODES_DIR``. They are read-only; their KB embeddings live in the shared ``bundled_tools`` collection (built once per machine per dsagt version by ``dsagt init``). Never copied into projects, so package upgrades automatically reach all existing projects. - * **Project tools** are agent-saved or user-edited specs in - ``/tools/``. Embeddings go into the project-local + * **Project codes** are agent-saved or user-edited specs in + ``/codes/``. Embeddings go into the project-local ``registered_tools`` collection on save. Listing / lookup methods merge both layers (project wins on name - collision so agents can override a bundled tool). Search the + collision so agents can override a bundled code). Search the KB-side via ``search_registry`` which queries both collections. """ - _PACKAGE_TOOLS_DIR = Path(__file__).parent / "tools" + _PACKAGE_CODES_DIR = Path(__file__).parent / "codes" def __init__( self, @@ -306,7 +306,7 @@ def __init__( kb: KnowledgeBase | None = None, ): self.runtime_dir = Path(runtime_dir) - self.tools_dir = self.runtime_dir / "tools" + self.codes_dir = self.runtime_dir / "codes" self._kb = kb self.runtime_dir.mkdir(parents=True, exist_ok=True) # Optional override of the package-bundled directory (used by @@ -315,55 +315,55 @@ def __init__( self._bundled_dir = ( Path(source_tools_dir) if source_tools_dir and Path(source_tools_dir).exists() - else self._PACKAGE_TOOLS_DIR + else self._PACKAGE_CODES_DIR ) - # Project tool dir is always agent-writable. We no longer - # pre-populate it with bundled tools — they're served directly - # from the package via the merge in list_tools / get_tool. - self.tools_dir.mkdir(parents=True, exist_ok=True) - # Ensure code/ subdirectory exists for agent-written scripts. - (self.tools_dir / "code").mkdir(exist_ok=True) + # Project code dir is always agent-writable. We no longer + # pre-populate it with bundled codes — they're served directly + # from the package via the merge in list_codes / get_code. + self.codes_dir.mkdir(parents=True, exist_ok=True) + # Ensure scripts/ subdirectory exists for agent-written scripts. + (self.codes_dir / "scripts").mkdir(exist_ok=True) - def _bundled_tool_paths(self) -> list[Path]: - """Return .md tool spec paths shipped with the package.""" + def _bundled_code_paths(self) -> list[Path]: + """Return .md code spec paths shipped with the package.""" if not self._bundled_dir.exists(): return [] return sorted(self._bundled_dir.glob("*.md")) - def _project_tool_paths(self) -> list[Path]: - """Return .md tool spec paths the agent has saved into this project.""" - return sorted(self.tools_dir.glob("*.md")) + def _project_code_paths(self) -> list[Path]: + """Return .md code spec paths the agent has saved into this project.""" + return sorted(self.codes_dir.glob("*.md")) - def list_tools_raw(self) -> list[dict]: - """Return full frontmatter dicts for all tools. + def list_codes_raw(self) -> list[dict]: + """Return full frontmatter dicts for all codes. - Merges bundled (package) + project (``/tools/``). - Project tools win on name collision so agents can override a - bundled tool with their own implementation. + Merges bundled (package) + project (``/codes/``). + Project codes win on name collision so agents can override a + bundled code with their own implementation. """ seen: dict[str, dict] = {} - for p in self._bundled_tool_paths(): + for p in self._bundled_code_paths(): spec = _parse_frontmatter(p) name = spec.get("name") if name: seen[name] = spec - for p in self._project_tool_paths(): + for p in self._project_code_paths(): spec = _parse_frontmatter(p) name = spec.get("name") if name: seen[name] = spec # project layer overrides bundled return [seen[name] for name in sorted(seen)] - def list_tools(self) -> list[dict]: - """List all tools with MCP-compatible schemas.""" - tools = [] - for tool in self.list_tools_raw(): - if not tool.get("name"): + def list_codes(self) -> list[dict]: + """List all codes with MCP-compatible schemas.""" + codes = [] + for code in self.list_codes_raw(): + if not code.get("name"): continue properties = {} required = [] - for param_name, param_def in tool.get("parameters", {}).items(): + for param_name, param_def in code.get("parameters", {}).items(): properties[param_name] = { "type": param_def.get("type", "string"), "description": param_def.get("description", ""), @@ -372,10 +372,10 @@ def list_tools(self) -> list[dict]: properties[param_name]["default"] = param_def["default"] if param_def.get("required", False): required.append(param_name) - tools.append( + codes.append( { - "name": tool["name"], - "description": tool["description"], + "name": code["name"], + "description": code["description"], "inputSchema": { "type": "object", "properties": properties, @@ -383,32 +383,32 @@ def list_tools(self) -> list[dict]: }, } ) - return tools + return codes - def get_tool(self, name: str) -> dict | None: - """Look up a tool spec by name. Project layer overrides bundled.""" - project_path = self.tools_dir / f"{name}.md" + def get_code(self, name: str) -> dict | None: + """Look up a code spec by name. Project layer overrides bundled.""" + project_path = self.codes_dir / f"{name}.md" if project_path.exists(): - tool = _parse_frontmatter(project_path) - if tool.get("name") == name: - return tool + code = _parse_frontmatter(project_path) + if code.get("name") == name: + return code bundled_path = self._bundled_dir / f"{name}.md" if bundled_path.exists(): - tool = _parse_frontmatter(bundled_path) - if tool.get("name") == name: - return tool + code = _parse_frontmatter(bundled_path) + if code.get("name") == name: + return code return None def save_tool(self, spec: dict) -> str: - """Write or update a tool file. Returns 'added' or 'updated'. + """Write or update a code file. Returns 'added' or 'updated'. Automatically wraps the executable: - With `uv run --with ` if Python dependencies are specified - - With `dsagt-run --tool ` for provenance capture + - With `dsagt-run --code ` for provenance capture - If a KnowledgeBase is available, indexes the tool for semantic search. + If a KnowledgeBase is available, indexes the code for semantic search. """ - path = self.tools_dir / f"{spec['name']}.md" + path = self.codes_dir / f"{spec['name']}.md" action = "updated" if path.exists() else "added" spec = dict(spec) @@ -427,27 +427,27 @@ def save_tool(self, spec: dict) -> str: body = parts[2] if not body: - body = _generate_tool_body(spec) + body = _generate_code_body(spec) frontmatter = yaml.dump(spec, default_flow_style=False, sort_keys=False) path.write_text(f"---\n{frontmatter}---\n{body}") if self._kb: - self._index_tool(spec, path) + self._index_code(spec, path) return action - def _index_tool(self, spec: dict, tool_path: Path) -> None: - """Index a tool file into the ``tools`` KB collection. + def _index_code(self, spec: dict, tool_path: Path) -> None: + """Index a code file into the ``codes`` KB collection. - Errors propagate to the caller — a tool that lives on disk but + Errors propagate to the caller — a code that lives on disk but isn't searchable in the KB is a half-broken state that the agent cannot recover from (it would write a duplicate next time it searched). Atomic registration: in the index or not registered. """ text = tool_path.read_text() metadata = { - "tool_name": spec["name"], + "code_name": spec["name"], "tags": ",".join(spec.get("tags", [])), "executable": spec["executable"], "has_dependencies": str(bool(spec.get("dependencies"))), @@ -455,25 +455,25 @@ def _index_tool(self, spec: dict, tool_path: Path) -> None: } self._kb.add_entries( texts=[text], - collection=TOOLS_COLLECTION, + collection=CODES_COLLECTION, metadatas=[metadata], ) def reindex_all(self) -> int: - """Reindex project-local tool files into the ``tools`` collection. + """Reindex project-local code files into the ``codes`` collection. - Returns count indexed. Bundled tools are NOT indexed here — they - live in the shared ``tools`` collection built and copied into the + Returns count indexed. Bundled codes are NOT indexed here — they + live in the shared ``codes`` collection built and copied into the project at ``dsagt init`` time. Search via ``search_registry`` queries the merged collection. """ if not self._kb: return 0 count = 0 - for path in self._project_tool_paths(): + for path in self._project_code_paths(): spec = _parse_frontmatter(path) if spec.get("name"): - self._index_tool(spec, path) + self._index_code(spec, path) count += 1 return count @@ -487,7 +487,7 @@ class SkillRegistry: """ Manages instruction-based agent skills and optional KB indexing. - Two layers (mirroring ToolRegistry): + Two layers (mirroring CodeRegistry): * **Bundled skills** ship with the dsagt package at ``_PACKAGE_SKILLS_DIR``. Read-only; embeddings live in shared @@ -586,7 +586,7 @@ def save_skill( skill_md = skill_dir / "SKILL.md" # Preserve hand-edited body when updating, unless caller passed - # an explicit replacement — same contract as ToolRegistry.save_tool. + # an explicit replacement — same contract as CodeRegistry.save_tool. if body is None and skill_md.exists(): existing = skill_md.read_text() parts = existing.split("---", 2) diff --git a/src/dsagt/session.py b/src/dsagt/session.py index a336fd8..ce141ac 100644 --- a/src/dsagt/session.py +++ b/src/dsagt/session.py @@ -11,11 +11,11 @@ .dsagt/ config.yaml # project configuration (MCP-server object settings) state.yaml # session log + memory cursor (owned by the MCP server) - explicit_memories.yaml, suggestions.json, ... # explicit memory + explicit_memories.yaml, ... # explicit memory trace_archive/ # tool execution records mlflow.db # serverless MLflow SQLite trace store (created lazily) tools/ # registered CLI tools - tools/code/ # agent-written tool scripts + codes/scripts/ # agent-written tool scripts skills/ # instruction-based agent skills kb_index/ # knowledge base collections """ @@ -29,9 +29,8 @@ import yaml -from dsagt.memory import extract_session from dsagt.knowledge import KnowledgeBase -from dsagt.provenance import ToolUseIndexer +from dsagt.provenance import CodeUseIndexer logger = logging.getLogger(__name__) @@ -97,20 +96,14 @@ def state_path(pdir: Path) -> Path: }, ], }, - # Episodic memory (Phase 3): the heartbeat's MemoryExtractor subscriber. - # ``enabled`` is a compute/storage opt-in (Tier-0 needs no credentials); - # ``judge`` selects the Tier-1 distillation backend (``local`` = LocalJudge, - # the no-API-key default — left empty/disabled until Tier-1 is wired on). - # ``domain_tags`` are the user's project-specific tags, merged onto the - # stock taxonomy. ``outlier_sensitivity`` 0 disables novelty queueing. + # Episodic memory: the heartbeat's MemoryExtractor subscriber. ``enabled`` + # is a compute/storage opt-in that mechanically chunks/tags/embeds each + # completed turn into session_memory (no credentials). "episodic": { "enabled": False, - "judge": {"backend": "", "model": ""}, - "domain_tags": {}, - "outlier_sensitivity": 0.0, - # Recency weighting for session_memory retrieval: a newer fact edges out + # Recency weighting for session_memory retrieval: a newer turn edges out # a stale one without contradiction detection. Half-life in days (a - # *boost*, never a penalty — durable old facts keep full relevance). + # *boost*, never a penalty — durable old turns keep full relevance). "recency_half_life_days": 14, }, } @@ -229,9 +222,9 @@ def list_projects() -> dict[str, str]: def kb_from_config(config: dict, index_dir: Path | None = None) -> "KnowledgeBase": """Build a KnowledgeBase from a resolved project config. - Mirrors the embedding-backend resolution used by ``extract_session`` so - callers (CLI ``skills`` group, catalog sync) get a KB wired to the same - embedder the project uses. Defaults to ``/kb_index``. + Resolves the project's embedding backend so callers (CLI ``skills`` group, + catalog sync) get a KB wired to the same embedder the project uses. + Defaults to ``/kb_index``. """ pdir = Path(config["project_dir"]) emb = config.get("embedding", {}) @@ -414,6 +407,26 @@ def update_cursor(pdir: Path, **fields) -> None: write_state(pdir, state) +def record_trace_source(pdir: Path, source) -> None: + """Stamp the current session's trace-source token into ``state.yaml``. + + ``source`` is the agent-shaped session token from + :meth:`dsagt.traces.Reader.active_source` (a transcript path, DB session id, + or session-dir name). The MCP server records it once the live collector + resolves a session, so the *next* session's catch-up can pin this exact + session — for *every* agent — when re-collecting turns an ungraceful + shutdown left unlogged. No-op if no session has been minted yet. + """ + state = read_state(pdir) + sessions = state.get("sessions") or [] + if not sessions: + return + if sessions[-1].get("trace_source") == source: + return # already recorded — avoid churning the file + sessions[-1]["trace_source"] = source + write_state(pdir, state) + + # --------------------------------------------------------------------------- # Project initialization # --------------------------------------------------------------------------- @@ -608,7 +621,7 @@ def init_project( pdir = (location or DEFAULT_PROJECTS_BASE) / project_name pdir.mkdir(parents=True, exist_ok=True) - # `tools/` and `tools/code/` are created by ToolRegistry on first server + # `tools/` and `codes/scripts/` are created by CodeRegistry on first server # startup so bundled tools get copied in (it short-circuits if tools/ # already exists). ``mlflow.db`` is created lazily by the MLflow client # on first span. @@ -711,51 +724,81 @@ def catch_up_extraction(pdir: Path, config: dict) -> dict: 1. **Tool-execution indexing** (always): embed the previous session's ``/trace_archive/`` records into the ``tool_use`` collection via - the shared :class:`~dsagt.provenance.ToolUseIndexer` — idempotent against - the same ``.dsagt/tool_use_acks.json`` the live heartbeat uses, so the + the shared :class:`~dsagt.provenance.CodeUseIndexer` — idempotent against + the same ``.dsagt/code_use_acks.json`` the live heartbeat uses, so the startup catch-up and the heartbeat never double-index. No LLM, no credentials (local-backend default). - 2. **Episodic LLM extraction** (gated on ``DSAGT_MEMORY_*``): forward- - looking plumbing. ``memory.extract_session`` is a no-op stub, so this - reports as unavailable even when the env is set. + 2. **Chat-trace catch-up** (:func:`_catch_up_traces`): re-collect the + previous session so any turns the heartbeat missed before an ungraceful + shutdown still reach MLflow (and episodic memory). Pinned to the + trace-source token recorded in ``state.yaml`` (uniform across agents); + session-qualified acks dedupe against the live pass, so only dangling + turns emit. """ pdir = Path(pdir) config = {**config, "project_dir": str(pdir)} kb = kb_from_config(config) - tool_use_indexed = 0 try: - tool_use_indexed = ToolUseIndexer(kb, pdir).tick() - except Exception as e: # noqa: BLE001 — never let a background task crash - logger.warning("Tool execution indexing failed: %s", e) - - # Episodic-memory extraction (stub until Phase 3). Skip silently if not - # configured. - api_key = os.environ.get("DSAGT_MEMORY_API_KEY", "") - model = os.environ.get("DSAGT_MEMORY_MODEL", "") - if not api_key or not model: + tool_use_indexed = 0 + try: + tool_use_indexed = CodeUseIndexer(kb, pdir).tick() + except Exception as e: # noqa: BLE001 — never let a background task crash + logger.warning("Tool execution indexing failed: %s", e) + + traces_caught_up = 0 + try: + traces_caught_up = _catch_up_traces(pdir, config, kb) + except Exception as e: # noqa: BLE001 — never let a background task crash + logger.warning("Trace catch-up failed: %s", e) + + return { + "status": "ok", + "tool_use_indexed": tool_use_indexed, + "traces_caught_up": traces_caught_up, + } + finally: kb.close() - return {"status": "tool_use_only", "tool_use_indexed": tool_use_indexed} + +def _catch_up_traces(pdir: Path, config: dict, kb) -> int: + """Re-collect the previous session's transcript; return turns newly emitted. + + Builds a trace collector pinned to the previous session's recorded + trace-source token (and tagged with its session id), then runs one + ``collect(include_last=True)``. The collector's session-qualified ack files + are shared with the live pass, so already-logged turns are skipped and only + those lost to an ungraceful shutdown are emitted to MLflow + episodic memory. + Uniform across agents — JSONL or SQLite — since the pin is the agent's own + session token, not a transcript-file assumption. + + Returns 0 (a no-op) when there is no previous session, or it stamped no + trace-source (a session too short for the heartbeat to record one), where + guessing would risk reading the *new* session's records. + """ + from dsagt.memory import episodic_consumers from dsagt.observability import resolve_tracking_uri + from dsagt.traces import make_trace_collector - try: - result = extract_session( - project_name=config.get("project", ""), - kb=kb, - api_key=api_key, - model=model, - base_url=os.environ.get("DSAGT_MEMORY_BASE_URL") or None, - provider=os.environ.get("DSAGT_MEMORY_PROVIDER") or None, - session_id=current_session_tag(pdir, config.get("project", "")), - categories=None, - runtime_dir=pdir, - outlier_sensitivity=float( - os.environ.get("DSAGT_MEMORY_OUTLIER_SENSITIVITY", "0") or 0 - ), - mlflow_uri=resolve_tracking_uri(config), - ) - result["tool_use_indexed"] = tool_use_indexed - return result - finally: - kb.close() + sessions = read_state(pdir).get("sessions") or [] + if len(sessions) < 2: + return 0 + prev = sessions[-2] + source = prev.get("trace_source") + if source is None: + return 0 + + project = config.get("project", "") + prev_tag = session_tag(project, prev["id"]) + collector = make_trace_collector( + config.get("agent"), + pdir, + project, + prev_tag, + resolve_tracking_uri(config), + extra_consumers=episodic_consumers(config, kb, pdir, prev_tag), + source=source, + ) + if collector is None: + return 0 + return collector.collect(include_last=True) diff --git a/src/dsagt/skills.py b/src/dsagt/skills.py index d3a3804..fbd1ca7 100644 --- a/src/dsagt/skills.py +++ b/src/dsagt/skills.py @@ -156,7 +156,7 @@ def rank_skills( # Catalog data plane — sources, sync/index, lookup/install, SkillsCatalog # =========================================================================== -#: Default source enabled out of the box (matches dsagt_config.yaml default). +#: Default source enabled out of the box (matches .dsagt/config.yaml default). DEFAULT_SOURCE = "k-dense-ai" #: Curated, named skill sources. ``subdir`` scopes the recursive SKILL.md diff --git a/src/dsagt/traces.py b/src/dsagt/traces.py index 136b987..637a3d5 100644 --- a/src/dsagt/traces.py +++ b/src/dsagt/traces.py @@ -1,7 +1,7 @@ """ Trace pipeline — read each agent's on-disk session, normalize it to one common ``Trace``, and hand that to whatever consumes traces (the MLflow logger in -:mod:`dsagt.observability`, the episodic-memory distiller in :mod:`dsagt.memory`). +:mod:`dsagt.observability`, the episodic-memory indexer in :mod:`dsagt.memory`). While a session runs, the MCP server wakes on a timer and, for the running agent: a **Reader** finds and reads the platform's session files on disk into raw @@ -41,7 +41,7 @@ └─▷ ClineTranslator fills parse hooks TraceCollector the driver: read → translate → hand to consumers - (MLflow logger, memory distiller); a per-consumer + (MLflow logger, memory indexer); a per-consumer ack set makes repeated passes idempotent """ @@ -316,12 +316,14 @@ def subset(self, root_ids: set[str]) -> "Trace": def to_exchanges(self) -> list[dict]: """Project the ``llm`` spans onto memory's conversational shape. - One ``llm`` span → one ``{timestamp, new_messages, response}`` exchange; - ``request`` is already the windowed message list, ``response`` the output - blocks — so this is a straight projection, no diffing. + One ``llm`` span → one ``{turn_id, timestamp, new_messages, response}`` + exchange; ``turn_id`` is the span id (groups a turn's chunks back + together downstream), ``request`` is already the windowed message list, + ``response`` the output blocks — so this is a straight projection. """ return [ { + "turn_id": s["span_id"], "timestamp": s["start_time"], "new_messages": s["request"], "response": s["response"], @@ -337,9 +339,33 @@ def to_exchanges(self) -> list[dict]: class Reader(ABC): - """Find this project's active session for an agent and read its records.""" + """Find this project's active session for an agent and read its records. + + Each reader resolves *the latest session for this project* and reads it. A + reader can also be pinned (:meth:`pin`) to a specific session — the startup + catch-up does this to re-read the *previous* session rather than whatever is + newest now. :meth:`active_source` returns an opaque, agent-shaped token for + the session being read — a transcript path (claude/codex), a DB session id + (goose/opencode), or a session-dir name (cline) — which the server records + in ``state.yaml`` so the next session's catch-up can pin it back. The token + round-trips through YAML, so its native type (str/int) is preserved. + """ agent: str + _pinned = None + + def pin(self, source) -> None: + """Pin to a specific session token (as returned by :meth:`active_source`).""" + self._pinned = source + + def active_source(self): + """Token identifying the session being read, or ``None`` if none. + + Subclasses override to resolve the latest session when unpinned; the + base returns the pinned token (``None`` when neither pinned nor + overridden). + """ + return self._pinned @abstractmethod def read(self) -> list[dict]: @@ -352,13 +378,24 @@ class JsonlReader(Reader): A trailing half-written line is dropped (picked up next pass). Subclasses supply :meth:`active_file`; the framing is identical for claude and codex. + A pinned reader reads that exact file instead of the newest. """ @abstractmethod def active_file(self) -> Path | None: ... + def _file(self) -> Path | None: + if self._pinned is not None: + p = Path(self._pinned) + return p if p.is_file() else None + return self.active_file() + + def active_source(self) -> str | None: + f = self._file() + return str(f) if f else None + def read(self) -> list[dict]: - f = self.active_file() + f = self._file() if f is None: return [] with open(f, "rb") as fh: @@ -452,24 +489,41 @@ def __init__(self, project_dir, *, db_path: Path | None = None): self._project_dir = os.path.abspath(project_dir) self._db = Path(db_path) if db_path else _GOOSE_DB + def _latest_session(self, con): + row = con.execute( + "SELECT id FROM sessions WHERE working_dir = ? " + "ORDER BY updated_at DESC LIMIT 1", + (self._project_dir,), + ).fetchone() + return row[0] if row else None + + def active_source(self): + if self._pinned is not None: + return self._pinned + if not self._db.exists(): + return None + con = sqlite3.connect(f"file:{self._db}?mode=ro", uri=True) + try: + return self._latest_session(con) + finally: + con.close() + def read(self) -> list[dict]: if not self._db.exists(): return [] con = sqlite3.connect(f"file:{self._db}?mode=ro", uri=True) try: - row = con.execute( - "SELECT id FROM sessions WHERE working_dir = ? " - "ORDER BY updated_at DESC LIMIT 1", - (self._project_dir,), - ).fetchone() - if row is None: + sid = ( + self._pinned if self._pinned is not None else self._latest_session(con) + ) + if sid is None: return [] return [ {"role": role, "content": _loads_list(cj), "ts": ts} for role, cj, ts in con.execute( "SELECT role, content_json, created_timestamp FROM messages " "WHERE session_id = ? ORDER BY id", - (row[0],), + (sid,), ) ] finally: @@ -494,19 +548,35 @@ def __init__(self, project_dir, *, db_path: Path | None = None): self._project_dir = os.path.abspath(project_dir) self._db = Path(db_path) if db_path else _OPENCODE_DB + def _latest_session(self, con): + row = con.execute( + "SELECT id FROM session WHERE directory = ? " + "ORDER BY time_updated DESC LIMIT 1", + (self._project_dir,), + ).fetchone() + return row[0] if row else None + + def active_source(self): + if self._pinned is not None: + return self._pinned + if not self._db.exists(): + return None + con = sqlite3.connect(f"file:{self._db}?mode=ro", uri=True) + try: + return self._latest_session(con) + finally: + con.close() + def read(self) -> list[dict]: if not self._db.exists(): return [] con = sqlite3.connect(f"file:{self._db}?mode=ro", uri=True) try: - row = con.execute( - "SELECT id FROM session WHERE directory = ? " - "ORDER BY time_updated DESC LIMIT 1", - (self._project_dir,), - ).fetchone() - if row is None: + sid = ( + self._pinned if self._pinned is not None else self._latest_session(con) + ) + if sid is None: return [] - sid = row[0] messages = { mid: _loads_dict(d) for mid, d in con.execute( @@ -564,8 +634,18 @@ def _active_dir(self) -> Path | None: return d return None + def _dir(self) -> Path | None: + if self._pinned is not None: + d = self._root / str(self._pinned) + return d if d.is_dir() else None + return self._active_dir() + + def active_source(self) -> str | None: + d = self._dir() + return d.name if d else None + def read(self) -> list[dict]: - d = self._active_dir() + d = self._dir() if d is None: return [] msgs_path = d / f"{d.name}.messages.json" @@ -1162,17 +1242,25 @@ def make_trace_collector( *, projects_root: Path | None = None, extra_consumers: list | None = None, + source=None, ) -> "TraceCollector | None": """Build the collector for ``agent``, or ``None`` if no pipeline is registered. The MLflow logger is always the first consumer (observability is universal); ``extra_consumers`` (e.g. a :class:`~dsagt.memory.MemoryExtractor` when episodic memory is enabled) are appended, each acking independently. + + ``source`` pins the reader to a specific session (the startup catch-up passes + the *previous* session's recorded :meth:`Reader.active_source` token), so it + re-reads that exact session instead of whatever is newest now — uniformly + across all agents (transcript path, DB session id, or session-dir name). """ builder = _PIPELINES.get(agent) if builder is None: return None reader, translator = builder(project_dir, projects_root) + if source is not None: + reader.pin(source) # Imported here (not at module top) so traces stays a lean leaf — the MLflow # logger drags in mlflow, the heaviest thing in the pipeline. from dsagt.observability import MLflowSink @@ -1192,8 +1280,10 @@ class TraceCollector: """Periodically read the session, translate it, and hand it to consumers. A *consumer* is anything with a ``name`` and a ``write(trace)`` — the MLflow - logger and the memory distiller both qualify (no shared base needed). Each - consumer keeps its own ack set (``.dsagt/trace_acks_.json``), so a + logger and the memory indexer both qualify (no shared base needed). Each + consumer keeps its own ack set (``.dsagt/trace_acks_.json``), keyed by + session-qualified turn id (``:``) so the per-transcript + ``turn-N`` indices can't collide across sessions in the shared file. A re-pass or an N+1 catch-up can only waste work, never double-log or lose a turn, and a failing consumer holds back only its own mark. @@ -1215,6 +1305,16 @@ def __init__( self._dsagt_dir = self._project_dir / ".dsagt" self._lock = threading.Lock() + def active_source(self): + """The reader's session token (see :meth:`Reader.active_source`), or + ``None``. Recorded in ``state.yaml`` so the next session's catch-up can + pin this exact session — uniform across all agents. + """ + try: + return self._reader.active_source() + except Exception: # noqa: BLE001 — best-effort; never break the heartbeat + return None + def _acks_path(self, name: str) -> Path: return self._dsagt_dir / f"trace_acks_{name}.json" @@ -1261,14 +1361,22 @@ def collect(self, *, include_last: bool = False) -> int: roots = trace.roots() candidates = roots if include_last else roots[:-1] - candidate_ids = [r["span_id"] for r in candidates] - if not candidate_ids: + # Ack keys are session-qualified. span_id is a per-transcript record + # index ("turn-N"), so the same ids recur in every session's + # transcript; a bare span_id would collide across sessions in the + # shared, never-reset ack file and suppress every turn after the + # first session. Qualifying by session id matches the key MLflowSink + # already uses for its own idempotency (``{trace_id}:{span_id}``). + key_by_span = { + r["span_id"]: f"{self._session_id}:{r['span_id']}" for r in candidates + } + if not key_by_span: return 0 emitted: set[str] = set() for consumer in self._consumers: acks = self._load_acks(consumer.name) - emit_ids = {i for i in candidate_ids if i not in acks} + emit_ids = {s for s, key in key_by_span.items() if key not in acks} if not emit_ids: continue try: @@ -1276,6 +1384,8 @@ def collect(self, *, include_last: bool = False) -> int: except Exception as e: # noqa: BLE001 — per-consumer isolation logger.warning("Trace consumer %r failed: %s", consumer.name, e) continue - self._save_acks(consumer.name, acks | emit_ids) + self._save_acks( + consumer.name, acks | {key_by_span[s] for s in emit_ids} + ) emitted |= emit_ids return len(emitted) diff --git a/tests/conftest.py b/tests/conftest.py index 8ed7715..3f3a3fa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,18 +4,16 @@ Integration-test credentials come from the project-root ``.env`` file — the same file smoke_test/run.sh sources. One source of truth beats two. -Tests that need real APIs use the ``embedding_config``, ``llm_config``, or -``integration_config`` fixtures. They FAIL loudly when ``.env`` is missing a -required variable, since an integration test with empty creds is worse than -a skipped one (silent false positive). +Tests that need real APIs use the ``embedding_config`` or ``llm_config`` +fixtures. They FAIL loudly when ``.env`` is missing a required variable, +since an integration test with empty creds is worse than a skipped one +(silent false positive). """ -import os import sys from pathlib import Path import pytest -import yaml # Ensure tests/ is on sys.path so mcp_helpers can be imported sys.path.insert(0, str(Path(__file__).parent)) @@ -77,47 +75,3 @@ def llm_config(): "base_url": _require(env, "LLM_BASE_URL"), "api_key": _require(env, "LLM_API_KEY"), } - - -@pytest.fixture -def integration_config(tmp_path, monkeypatch): - """Full DSAGT config as ``load_config()`` would return it. - - Builds a ``.dsagt/config.yaml`` in a temp project dir from .env values, - then patches the registry so ``load_config(project_name)`` resolves to it. - """ - from dsagt.session import load_config - - env = _load_env() - project_name = "test-integration" - config_data = { - "project": project_name, - "agent": "claude", - "embedding": { - "backend": "api", - "model": _require(env, "EMBEDDING_MODEL"), - "base_url": _require(env, "EMBEDDING_BASE_URL"), - }, - } - - project_dir = tmp_path / project_name - project_dir.mkdir(parents=True) - for subdir in ( - "trace_archive", - "tools", - "tools/code", - "skills", - "kb_index", - ".dsagt", - ): - (project_dir / subdir).mkdir() - (project_dir / ".dsagt" / "config.yaml").write_text( - yaml.dump(config_data, default_flow_style=False, sort_keys=False) - ) - - monkeypatch.setattr( - "dsagt.session._load_registry", - lambda: {project_name: str(project_dir)}, - ) - - return load_config(project_name) diff --git a/tests/smoke_test/manual_runs/cli_walkthrough.md b/tests/smoke_test/manual_runs/cli_walkthrough.md index 2814e86..54df938 100644 --- a/tests/smoke_test/manual_runs/cli_walkthrough.md +++ b/tests/smoke_test/manual_runs/cli_walkthrough.md @@ -44,11 +44,11 @@ cd ~/dsagt-projects/smoke- && 2. > I have a CSV utility called `csvtool`. Its reference is at `$SMOKE_DIR/knowledge/api_reference.md` — register the `filter` subcommand. Use an underscore in the name. - **Expect:** `save_tool_spec` MCP call; `~/dsagt-projects/smoke-/tools/csvtool_filter.md` exists. + **Expect:** `save_code_spec` MCP call; `~/dsagt-projects/smoke-/codes/csvtool_filter.md` exists. 3. > Use the `scan_directory` tool from the registry to scan `$SMOKE_DIR/data/`. - **Expect:** Agent invokes `dsagt-run --tool scan_directory ...`; one record in `~/dsagt-projects/smoke-/trace_archive/scan_directory_*.json`. + **Expect:** Agent invokes `dsagt-run --code scan_directory ...`; one record in `~/dsagt-projects/smoke-/trace_archive/scan_directory_*.json`. 4. > Look at `$SMOKE_DIR/data/samples.csv` and summarize — columns, row count, quality issues. @@ -68,7 +68,7 @@ Exit the agent (Ctrl+C / `/exit`). ```bash dsagt info smoke- -ls ~/dsagt-projects/smoke-/tools/ +ls ~/dsagt-projects/smoke-/codes/ ls ~/dsagt-projects/smoke-/trace_archive/ test -s ~/dsagt-projects/smoke-/explicit_memories.yaml && echo OK ``` diff --git a/tests/smoke_test/manual_runs/vscode_walkthrough.md b/tests/smoke_test/manual_runs/vscode_walkthrough.md index 49ea54c..50a0234 100644 --- a/tests/smoke_test/manual_runs/vscode_walkthrough.md +++ b/tests/smoke_test/manual_runs/vscode_walkthrough.md @@ -53,7 +53,7 @@ Close the extension chat / VS Code session when done. ```bash dsagt info smoke- -ls ~/dsagt-projects/smoke-/tools/ +ls ~/dsagt-projects/smoke-/codes/ ls ~/dsagt-projects/smoke-/trace_archive/ test -s ~/dsagt-projects/smoke-/explicit_memories.yaml && echo OK ``` diff --git a/tests/smoke_test/run.sh b/tests/smoke_test/run.sh index bb8ee8b..c6fe522 100755 --- a/tests/smoke_test/run.sh +++ b/tests/smoke_test/run.sh @@ -131,7 +131,7 @@ check() { fi } -check "csvtool_filter spec written" "test -f '${PDIR}/tools/csvtool_filter.md'" +check "csvtool_filter spec written" "test -f '${PDIR}/codes/csvtool_filter.md'" check "trace_archive has records" "ls '${PDIR}/trace_archive/'*.json | grep -q ." check "scan_directory record" "ls '${PDIR}/trace_archive/'*scan_directory*.json | grep -q ." # Both files are written by dsagt-server's kb_ingest MCP tool — chroma.sqlite3 @@ -179,7 +179,7 @@ n = 0 for _, row in df.iterrows(): spans = row.get("spans") or [] # Match by service.name on root span — agent-emitted traces only. - # MCP-server traces (kb.*, registry.*, tool.execute) carry + # MCP-server traces (kb.*, registry.*, code.execute) carry # service.name = "dsagt-server" / "dsagt-run" and shouldn't count # toward agent turn parity. for s in spans: diff --git a/tests/test_chroma_metadata.py b/tests/test_chroma_metadata.py index e928114..7504839 100644 --- a/tests/test_chroma_metadata.py +++ b/tests/test_chroma_metadata.py @@ -134,21 +134,16 @@ def test_search_with_compound_where(self, indexed): assert set(indices.tolist()) == {3} def test_search_where_no_matches(self, indexed): - """search() with where that matches nothing returns empty or raises.""" + """search() with a where that matches nothing returns empty arrays.""" query = np.ones(8, dtype=np.float32) query /= np.linalg.norm(query) - # ChromaDB behavior varies by version: either returns empty results - # or raises when where matches nothing. Both are acceptable. - try: - scores, indices = indexed.search( - query, - k=4, - where={"tool_name": "nonexistent"}, - ) - assert len(scores) == 0 - assert len(indices) == 0 - except (ValueError, RuntimeError): - pass # ChromaDB raised on no-match — acceptable + scores, indices = indexed.search( + query, + k=4, + where={"tool_name": "nonexistent"}, + ) + assert len(scores) == 0 + assert len(indices) == 0 def test_search_empty_index(self): """search() on empty index returns empty arrays.""" diff --git a/tests/test_config.py b/tests/test_config.py index 95e794f..b5fa34e 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -302,12 +302,11 @@ def ask(self): assert s["agent"] == "claude" assert s["knowledge"] == {"collections": []} assert [src["name"] for src in s["skills"]["sources"]] == ["genesis"] - assert s["assets"] == ["tools", "genesis"] # tools always provisioned + assert s["assets"] == ["codes", "genesis"] # tools always provisioned assert s["episodic"] is None # opt-in, off by default def test_interactive_episodic_enabled(self): - """Enabling episodic at the prompt yields a Tier-1 local-judge block - with the free-text domain tags merged in.""" + """Enabling episodic at the prompt yields the opt-in block.""" import types import questionary from unittest.mock import patch @@ -325,16 +324,10 @@ def ask(self): patch.object(questionary, "select", lambda *a, **k: _Ask("claude")), patch.object(questionary, "checkbox", lambda *a, **k: _Ask([])), patch.object(cli, "_confirm", lambda *a, **k: True), - patch.object(cli, "_prompt", lambda *a, **k: "assembly, variant_calling"), ): s = cli._collect_settings(args, interactive=True, existing={}, pdir=None) - assert s["episodic"]["enabled"] is True - assert s["episodic"]["judge"]["backend"] == "local" - assert s["episodic"]["domain_tags"] == { - "assembly": "assembly", - "variant_calling": "variant_calling", - } + assert s["episodic"] == {"enabled": True} def test_non_interactive_splits_assets(self): """No-TTY path splits --include into collections vs skill sources; @@ -343,34 +336,28 @@ def test_non_interactive_splits_assets(self): from dsagt.commands import cli args = types.SimpleNamespace( - agent="goose", include=["tools", "nemo_curator", "anthropic"], exclude=None + agent="goose", include=["codes", "nemo_curator", "anthropic"], exclude=None ) s = cli._collect_settings(args, interactive=False, existing={}, pdir=None) assert s["agent"] == "goose" assert s["knowledge"]["collections"] == ["nemo_curator"] assert [src["name"] for src in s["skills"]["sources"]] == ["anthropic"] - assert s["assets"] == ["tools", "nemo_curator", "anthropic"] + assert s["assets"] == ["codes", "nemo_curator", "anthropic"] assert s["episodic"] is None def test_non_interactive_episodic_flag(self): - """--episodic + --domain-tags build the Tier-1 block on the no-TTY path.""" + """--episodic builds the opt-in block on the no-TTY path.""" import types from dsagt.commands import cli args = types.SimpleNamespace( agent="goose", - include=["tools"], + include=["codes"], exclude=None, episodic=True, - domain_tags="proteomics, qc_metrics", ) s = cli._collect_settings(args, interactive=False, existing={}, pdir=None) - assert s["episodic"]["enabled"] is True - assert s["episodic"]["judge"]["backend"] == "local" - assert s["episodic"]["domain_tags"] == { - "proteomics": "proteomics", - "qc_metrics": "qc_metrics", - } + assert s["episodic"] == {"enabled": True} # --------------------------------------------------------------------------- @@ -389,9 +376,9 @@ def test_creates_directory_structure(self): assert (pdir / "skills").is_dir() assert (pdir / "kb_index").is_dir() assert (pdir / ".dsagt").is_dir() - # `tools/` is intentionally NOT created by init_project — ToolRegistry + # `tools/` is intentionally NOT created by init_project — CodeRegistry # creates it on first server startup so bundled tools get copied in. - assert not (pdir / "tools").exists() + assert not (pdir / "codes").exists() # Serverless: no MLflow store is pre-created; ``mlflow.db`` is # written lazily by the MLflow client on first span. assert not (pdir / "mlflow.db").exists() @@ -433,7 +420,7 @@ def test_default_init_provisions_default_asset_set(self): init_project("myproj", "goose") mock_ensure.assert_called_once() requested = mock_ensure.call_args.args[0] - assert requested == ["tools", "genesis"] + assert requested == ["codes", "genesis"] def test_reinit_is_idempotent_update(self): """``dsagt init`` is re-runnable: a second init on the same project @@ -450,16 +437,10 @@ def test_invalid_agent_raises(self): def test_episodic_block_round_trips_through_config(self): """init_project writes the opted-in episodic block; load_config reads it back. A project without it backfills ``enabled: False`` from DEFAULTS.""" - epi = { - "enabled": True, - "judge": {"backend": "local", "model": ""}, - "domain_tags": {"assembly": "assembly"}, - "outlier_sensitivity": 0.0, - } + epi = {"enabled": True} init_project("withmem", "goose", exclude=["all"], episodic=epi) cfg = load_config("withmem") assert cfg["episodic"]["enabled"] is True - assert cfg["episodic"]["domain_tags"] == {"assembly": "assembly"} init_project("nomem", "goose", exclude=["all"]) cfg2 = load_config("nomem") @@ -932,23 +913,6 @@ def test_warns_when_project_has_no_creds_but_shell_does(self, caplog, monkeypatc # Var names only, never values. assert "shell-key" not in msgs - def test_no_warning_when_project_supplies_creds(self, caplog): - from dsagt.agents import agent_env - - cfg = self._config( - agent="claude", - provider="anthropic", - api_key="project-key", - base_url="https://api.anthropic.com", - model="claude-haiku", - ) - - with caplog.at_level("WARNING", logger="dsagt.agents"): - agent_env(cfg) - - msgs = " ".join(r.getMessage() for r in caplog.records) - assert "preconfigured env vars" not in msgs - def test_warns_with_no_shell_either_lists_expected_vars(self, caplog, monkeypatch): """When neither project nor shell has creds, surface the var names the agent's runtime expects so the user can fix the gap.""" @@ -963,6 +927,9 @@ def test_warns_with_no_shell_either_lists_expected_vars(self, caplog, monkeypatc msgs = " ".join(r.getMessage() for r in caplog.records) assert "fall back to its own auth flow" in msgs assert "ANTHROPIC_API_KEY" in msgs + # A clean shell must not trigger the preconfigured-creds warning + # (the warning keys off shell env only, never the project YAML). + assert "preconfigured env vars" not in msgs # --------------------------------------------------------------------------- @@ -1021,12 +988,7 @@ def test_default_config_mirrors_init_choices(self): def test_episodic_written_only_when_enabled(self): """An opted-in episodic block is written verbatim; absent otherwise (and ``load_config`` backfills ``enabled: false`` for the absent case).""" - epi = { - "enabled": True, - "judge": {"backend": "local", "model": ""}, - "domain_tags": {"assembly": "assembly"}, - "outlier_sensitivity": 0.0, - } + epi = {"enabled": True} parsed = yaml.safe_load(default_config_content("t", "claude", episodic=epi)) assert parsed["episodic"] == epi # Omitted when None. diff --git a/tests/test_dependency_integration.py b/tests/test_dependency_integration.py index 4b15701..5a4dfce 100644 --- a/tests/test_dependency_integration.py +++ b/tests/test_dependency_integration.py @@ -2,7 +2,7 @@ Integration test for dependency installation during tool registration. Registers a tool with a real dependency (cowsay), installs it via the -registry server, then executes the tool through the pipeline's ToolRegistry +registry server, then executes the tool through the pipeline's CodeRegistry to verify the package is usable. This test actually modifies the venv (installs and uninstalls cowsay). @@ -23,16 +23,12 @@ import textwrap from pathlib import Path -import pytest - -pytestmark = pytest.mark.integration - import pytest import yaml from dsagt.mcp.registry_tools import create_registry_server -from dsagt.registry import ToolRegistry +from dsagt.registry import CodeRegistry # --------------------------------------------------------------------------- # Skip conditions @@ -52,6 +48,7 @@ def _cowsay_installed() -> bool: pytestmark = [ + pytest.mark.integration, pytest.mark.skipif(not _uv_available(), reason="uv not available on PATH"), pytest.mark.skipif(_cowsay_installed(), reason="cowsay already installed"), ] @@ -97,8 +94,8 @@ def test_register_and_run_tool_with_dependency(tmp_path): print(json.dumps({"cow_says": output, "status": "ok"})) """)) - # 2. Create a registry server with a fresh ToolRegistry - registry = ToolRegistry( + # 2. Create a registry server with a fresh CodeRegistry + registry = CodeRegistry( source_tools_dir=None, runtime_dir=str(tmp_path / "runtime"), ) @@ -118,14 +115,14 @@ def test_register_and_run_tool_with_dependency(tmp_path): }, }, } - text = call_tool(server, "save_tool_spec", {"spec": spec}) + text = call_tool(server, "save_code_spec", {"spec": spec}) # Verify the tool was saved and deps were installed assert "added" in text assert "Successfully installed" in text # 4. Verify the spec is in the skill file with dsagt-run wrapping - tool = registry.get_tool("cowsay_tool") + tool = registry.get_code("cowsay_tool") assert tool is not None assert tool["dependencies"] == ["cowsay"] assert "dsagt-run" in tool["executable"] diff --git a/tests/test_dsagt_server.py b/tests/test_dsagt_server.py index a23e16a..a125fdf 100644 --- a/tests/test_dsagt_server.py +++ b/tests/test_dsagt_server.py @@ -18,7 +18,7 @@ import pytest from dsagt.mcp.server import _build_kb_from_config, create_dsagt_server -from dsagt.registry import SkillRegistry, ToolRegistry +from dsagt.registry import SkillRegistry, CodeRegistry def _make_merged_server(tmp_path: Path): @@ -28,7 +28,7 @@ def _make_merged_server(tmp_path: Path): kb.default_rerank = True kb.collections = [] runtime = str(tmp_path / "runtime") - reg = ToolRegistry(source_tools_dir=None, runtime_dir=runtime, kb=None) + reg = CodeRegistry(source_tools_dir=None, runtime_dir=runtime, kb=None) sreg = SkillRegistry(source_skills_dir=None, runtime_dir=runtime, kb=None) return create_dsagt_server(reg, kb, sreg, runtime_dir=runtime) @@ -53,18 +53,34 @@ def test_merged_server_exposes_all_tools(tmp_path): """Both concern modules' tools land under one server with no collision.""" server = _make_merged_server(tmp_path) names = _list_tools(server) - # 8 registry + 5 knowledge + 4 memory + 5 skill = 22 distinct tools. - # (kb_add_vector_db is gated off until BYO lands — see knowledge_tools.py.) - assert len(names) == 22 - assert len(set(names)) == len(names) # no name collision - # Representative tools from each side. - for expected in ( + # 8 registry + 5 knowledge + 2 memory + 5 skill = 20 distinct tools. + assert set(names) == { + # registry / provenance (8) "get_registry", - "search_skills", + "search_registry", + "save_code_spec", + "install_dependencies", + "run_command", + "read_file", + "http_request", + "reconstruct_pipeline", + # knowledge (5) "kb_search", + "kb_ingest", + "kb_list_collections", + "kb_job_status", + "kb_append", + # memory (2) + "kb_remember", + "kb_get_memories", + # skills (5) + "search_skills", + "install_skill", + "save_skill", + "add_skill_source", "list_skill_sources", - ): - assert expected in names + } + assert len(set(names)) == len(names) # no name collision def test_registry_tool_returns_plain_string(tmp_path): @@ -74,11 +90,11 @@ def test_registry_tool_returns_plain_string(tmp_path): # Not JSON — the registry contract is a human-readable string. with pytest.raises(json.JSONDecodeError): json.loads(out) - assert "tools:" in out + assert "codes:" in out -def test_knowledge_tool_returns_json(tmp_path): - """Knowledge handlers return a dict — JSON-encoded by the wrapper.""" +def test_dict_returning_handler_is_json_encoded(tmp_path): + """Dict-returning handlers (knowledge/memory/skill) are JSON-encoded by the wrapper.""" server = _make_merged_server(tmp_path) out = _call(server, "list_skill_sources", {}) parsed = json.loads(out) diff --git a/tests/test_episodic_integration.py b/tests/test_episodic_integration.py index 905d2c2..bb7d785 100644 --- a/tests/test_episodic_integration.py +++ b/tests/test_episodic_integration.py @@ -1,13 +1,13 @@ -"""End-to-end episodic-memory integration: heartbeat → judge → session_memory. +"""End-to-end episodic-memory integration: heartbeat → session_memory. -The full Phase-3 chain with nothing faked: a real Claude transcript on disk → -``TraceCollector`` (the heartbeat) → the ``MemoryExtractor`` consumer built by the -server's own ``_episodic_consumers`` wiring → the real ``LocalJudge`` (GGUF -inference) → distilled facts embedded into the ``session_memory`` collection of a -real ``KnowledgeBase`` → retrieved by ``kb.search``. +The full episodic chain with nothing faked: a real Claude transcript on +disk → ``TraceCollector`` (the heartbeat) → the ``MemoryExtractor`` consumer +built by the shared ``memory.episodic_consumers`` wiring → mechanically tagged +turns embedded into the ``session_memory`` collection of a real +``KnowledgeBase`` → retrieved by ``kb.search``. -Marked ``integration``: loads the local embedder and downloads/loads the ~1GB -GGUF judge, so it's deselected from the fast suite (``-m 'not integration'``). +Marked ``integration``: loads the local embedder, so it's deselected from the +fast suite (``-m 'not integration'``). """ import json @@ -42,26 +42,19 @@ def _user(ts, text, uuid): @pytest.mark.integration -def test_heartbeat_distills_a_fact_into_session_memory(tmp_path): +def test_heartbeat_indexes_turn_into_session_memory(tmp_path): project_dir = tmp_path / "proj" (project_dir / ".dsagt").mkdir(parents=True) # A real KB with the local embedder (the project's session_memory lives here). kb = KnowledgeBase(index_dir=project_dir / "kb_index", default_embedder="local") - # Episodic enabled with the Tier-1 local judge — exactly what `dsagt init - # --episodic` writes — fed through the server's real subscriber builder. - from dsagt.mcp.server import _episodic_consumers - - config = { - "episodic": { - "enabled": True, - "judge": {"backend": "local", "model": ""}, - "domain_tags": {}, - "outlier_sensitivity": 0.0, - } - } - subs = _episodic_consumers(config, kb, project_dir, "proj:s") + # Episodic enabled — exactly what `dsagt init --episodic` writes — fed + # through the server's real subscriber builder. + from dsagt.memory import episodic_consumers + + config = {"episodic": {"enabled": True}} + subs = episodic_consumers(config, kb, project_dir, "proj:s") assert subs and subs[0].name == "memory" # A real Claude transcript with one clearly fact-bearing completed turn, @@ -97,7 +90,7 @@ def test_heartbeat_distills_a_fact_into_session_memory(tmp_path): extra_consumers=subs, ) - # include_last=True flushes both turns; the memory consumer distills them. + # include_last=True flushes both turns; the memory consumer indexes them. n = collector.collect(include_last=True) assert n >= 1 @@ -105,18 +98,52 @@ def test_heartbeat_distills_a_fact_into_session_memory(tmp_path): assert collector._load_acks("mlflow") # observability sink assert collector._load_acks("memory") # episodic consumer - # The distilled fact is retrievable from session_memory, tagged as a Tier-1 - # fact from this session. + # A mechanically-indexed block-chunk is retrievable from session_memory, + # tagged with its producer + turn from this session. hits = kb.search( "fastp quality filtering Q30", collection=SESSION_MEMORY_COLLECTION, top_k=5 ) - assert hits, "expected at least one distilled fact in session_memory" + assert hits, "expected at least one indexed chunk in session_memory" chunk = hits[0]["chunk"] text = chunk["text"].lower() assert "fastp" in text or "q30" in text or "qc" in text meta = chunk["metadata"] - assert meta.get("source_type") == "fact" - assert meta.get("tier") == "1" + assert meta.get("source_type") == "turn" assert meta.get("session_id") == "proj:s" + assert meta.get("producer") in {"user", "llm", "tool"} + assert meta.get("turn_id") # groups chunks back into their turn + + kb.close() + + +@pytest.mark.integration +def test_where_document_regex_and_contains_filter_session_memory(tmp_path): + """The metadata-filter + regex strategy: a document-text filter narrows the + candidate pool before semantic ranking, end-to-end through a real KB.""" + kb = KnowledgeBase(index_dir=tmp_path / "kb_index", default_embedder="local") + kb.add_entries( + texts=["fastp filtered reads at Q30", "bowtie2 aligned the reads"], + collection=SESSION_MEMORY_COLLECTION, + metadatas=[{"producer": "llm"}, {"producer": "llm"}], + ) + + # $regex narrows to the fastp chunk (case-insensitive) despite both being + # about "reads"; the metadata-filter path is dense-only but still ranks. + hits = kb.search( + "reads", + collection=SESSION_MEMORY_COLLECTION, + top_k=5, + where_document={"$regex": "(?i)FASTP"}, + ) + assert [h["chunk"]["text"] for h in hits] == ["fastp filtered reads at Q30"] + + # $contains is a case-sensitive substring. + hits2 = kb.search( + "reads", + collection=SESSION_MEMORY_COLLECTION, + top_k=5, + where_document={"$contains": "bowtie2"}, + ) + assert [h["chunk"]["text"] for h in hits2] == ["bowtie2 aligned the reads"] kb.close() diff --git a/tests/test_extraction.py b/tests/test_extraction.py deleted file mode 100644 index a29a126..0000000 --- a/tests/test_extraction.py +++ /dev/null @@ -1,174 +0,0 @@ -""" -Tests for memory-extraction helpers. - -Episodic extraction itself (``extract_session``) is a no-op stub post-proxy -— the proxy-shape trace reader and the LiteLLM extraction call were removed -with the proxy, and Phase 3 rebuilds extraction over the CanonicalTrace -pipeline. The prompt builder and response parser are retained as Phase-3 -building blocks and still have unit coverage here. -""" - -import json - -from dsagt.memory import ( - build_extraction_prompt, - extract_session, - parse_extraction_response, -) - -# --------------------------------------------------------------------------- -# build_extraction_prompt -# --------------------------------------------------------------------------- - - -class TestBuildExtractionPrompt: - - def _make_exchanges(self): - return [ - { - "timestamp": "2024-01-15T10:30:00Z", - "model": "claude-sonnet-4-20250514", - "new_messages": [ - {"role": "user", "content": "Run fastp on sample1.fq.gz"} - ], - "response": [ - {"type": "text", "text": "I'll run fastp with Q20 filtering."}, - { - "type": "tool_use", - "name": "bash", - "input": {"cmd": "fastp -q 20 --in1 sample1.fq.gz"}, - }, - ], - }, - { - "timestamp": "2024-01-15T10:31:00Z", - "model": "claude-sonnet-4-20250514", - "new_messages": [ - { - "role": "user", - "content": [ - { - "type": "tool_result", - "tool_use_id": "t1", - "content": "98% reads passed", - }, - ], - }, - ], - "response": [ - { - "type": "text", - "text": "Filtering complete. 98% of reads passed Q20.", - }, - ], - }, - ] - - def test_includes_conversation(self): - prompt = build_extraction_prompt(self._make_exchanges()) - assert "fastp" in prompt - assert "sample1.fq.gz" in prompt - assert "98% reads passed" in prompt - - def test_includes_categories(self): - prompt = build_extraction_prompt(self._make_exchanges()) - assert "quality_control" in prompt - assert "data_management" in prompt - - def test_custom_categories_merged(self): - custom = {"my_category": "custom stuff"} - prompt = build_extraction_prompt(self._make_exchanges(), categories=custom) - assert "my_category" in prompt - assert "custom stuff" in prompt - assert "quality_control" in prompt # stock still present - - def test_output_format_described(self): - prompt = build_extraction_prompt(self._make_exchanges()) - assert '"facts"' in prompt - assert '"summary"' in prompt - assert '"insights"' in prompt - - def test_empty_exchanges(self): - prompt = build_extraction_prompt([]) - assert "facts" in prompt # prompt structure still present - - -# --------------------------------------------------------------------------- -# parse_extraction_response -# --------------------------------------------------------------------------- - - -class TestParseExtractionResponse: - - def test_valid_json(self): - response = json.dumps( - { - "facts": [{"text": "fastp used Q20", "category": "quality_control"}], - "summary": "Ran fastp on sample1.", - "insights": [ - { - "text": "Q20 is sufficient for isolates", - "category": "quality_control", - } - ], - } - ) - result = parse_extraction_response(response) - assert len(result["facts"]) == 1 - assert result["summary"] == "Ran fastp on sample1." - assert len(result["insights"]) == 1 - - def test_strips_markdown_fences(self): - response = ( - "```json\n" - + json.dumps( - { - "facts": [], - "summary": "test", - "insights": [], - } - ) - + "\n```" - ) - result = parse_extraction_response(response) - assert result["summary"] == "test" - - def test_missing_fields_default(self): - result = parse_extraction_response("{}") - assert result["facts"] == [] - assert result["summary"] == "" - assert result["insights"] == [] - - -# --------------------------------------------------------------------------- -# extract_session — Phase-3 stub -# --------------------------------------------------------------------------- - - -class TestExtractSessionStub: - """Until Phase 3 rebuilds extraction, ``extract_session`` is a no-op - that reports unavailability without reading traces or calling an LLM.""" - - def test_returns_unavailable_without_touching_kb_or_network(self): - result = extract_session( - project_name="proj", - kb=None, - api_key="test-key", - session_id="test-session", - ) - assert result["status"] == "extraction_unavailable" - assert result["facts"] == 0 - assert result["insights"] == 0 - assert result["total_entries"] == 0 - assert result["session_id"] == "test-session" - - def test_stub_ignores_injected_exchanges(self): - result = extract_session( - project_name="proj", - kb=None, - api_key="test-key", - session_id="s", - exchanges=[{"new_messages": [], "response": []}], - ) - assert result["status"] == "extraction_unavailable" - assert result["total_entries"] == 0 diff --git a/tests/test_info.py b/tests/test_info.py index 3a268b0..93a54eb 100644 --- a/tests/test_info.py +++ b/tests/test_info.py @@ -38,20 +38,14 @@ def _metadata(*, session: str, agent: str | None, in_t: int, out_t: int) -> dict return md -def _spans_for(service_name: str | None) -> list: - """Build the ``spans`` column entry: one root span carrying service.name. - - ``_source_from_spans`` reads ``service.name`` off the root span's - attributes (MLflow's OTLP receiver flows the OTel resource attribute - through to span attributes), so a single SimpleNamespace is enough - for the report-side test. ``None`` → empty spans list, source falls - through to ``"unknown"``. - """ - from types import SimpleNamespace +def _tags_for(source: str | None) -> dict: + """Build the ``tags`` column entry carrying the ``dsagt.source`` bucket. - if service_name is None: - return [] - return [SimpleNamespace(attributes={"service.name": service_name})] + Internal debug traces are bucketed from this tag (the MCP tool category / + ``execution``); ``None`` → no tag, so the trace falls back to the + ``dsagt.agent`` metadata (agent traces) or ``"unknown"``. + """ + return {"dsagt.source": source} if source else {} def _traces_df(rows: list[dict]) -> pd.DataFrame: @@ -135,11 +129,11 @@ def test_report_empty_traces(config): def test_report_aggregates_by_source_and_session(config): - """Source bucketing comes from the root span's ``service.name``. + """Source bucketing: agent traces from ``dsagt.agent`` metadata, internal + traces from the ``dsagt.source`` tag. - Three claude-code agent traces (one of which errored) + one - knowledge-server trace, across two sessions. Sums + bucket counts - match expected totals. + Three claude agent traces (one of which errored) + one internal + knowledge-tool trace, across two sessions. Sums + bucket counts match. """ df = _traces_df( [ @@ -153,7 +147,7 @@ def test_report_aggregates_by_source_and_session(config): in_t=1000, out_t=100, ), - "spans": _spans_for("claude-code"), + "tags": _tags_for(None), }, { "trace_id": "t2", @@ -165,7 +159,7 @@ def test_report_aggregates_by_source_and_session(config): in_t=500, out_t=50, ), - "spans": _spans_for("claude-code"), + "tags": _tags_for(None), }, { "trace_id": "t3", @@ -177,7 +171,7 @@ def test_report_aggregates_by_source_and_session(config): in_t=200, out_t=0, ), - "spans": _spans_for("dsagt-server"), + "tags": _tags_for("knowledge"), }, { "trace_id": "t4", @@ -189,7 +183,7 @@ def test_report_aggregates_by_source_and_session(config): in_t=800, out_t=20, ), - "spans": _spans_for("claude-code"), + "tags": _tags_for(None), }, ] ) @@ -202,12 +196,15 @@ def test_report_aggregates_by_source_and_session(config): assert r["output_tokens"] == 170 sources = {row["source"]: row for row in r["by_source"]} - assert sources["claude-code"]["traces"] == 3 - assert sources["claude-code"]["input_tokens"] == 2300 - assert sources["claude-code"]["output_tokens"] == 170 - assert sources["claude-code"]["errors"] == 1 - assert sources["dsagt-server"]["traces"] == 1 - assert sources["dsagt-server"]["errors"] == 0 + # t1/t2/t4 have no dsagt.source tag → bucket by dsagt.agent ("claude"). + assert sources["claude"]["traces"] == 3 + assert sources["claude"]["input_tokens"] == 2300 + assert sources["claude"]["output_tokens"] == 170 + assert sources["claude"]["errors"] == 1 + # t3 carries dsagt.source=knowledge → its own bucket even though it also + # has dsagt.agent (the tag wins for internal traces). + assert sources["knowledge"]["traces"] == 1 + assert sources["knowledge"]["errors"] == 0 assert [s["session"] for s in r["by_session"]] == ["sess-B", "sess-A"] sess_a = next(s for s in r["by_session"] if s["session"] == "sess-A") @@ -218,12 +215,12 @@ def test_report_aggregates_by_source_and_session(config): assert len(r["errors"]) == 1 err = r["errors"][0] assert err["session"] == "sess-B" - assert err["source"] == "claude-code" + assert err["source"] == "claude" assert err["trace_id"] == "t4" def test_report_missing_source_falls_back_to_unknown(config): - """No spans column / no service.name → bucket is "unknown".""" + """No dsagt.source tag and no dsagt.agent → bucket is "unknown".""" df = _traces_df( [ { @@ -236,7 +233,7 @@ def test_report_missing_source_falls_back_to_unknown(config): in_t=0, out_t=0, ), - "spans": _spans_for(None), + "tags": _tags_for(None), }, ] ) @@ -261,7 +258,6 @@ def test_mask_secret_long_value(): def _write_project(tmp_path, monkeypatch, raw_yaml: str): """Register a temp project with the given .dsagt/config.yaml content.""" - import yaml as _yaml from dsagt.session import register_project pdir = tmp_path / "proj" @@ -373,14 +369,14 @@ def test_kb_collections_counts_chunks_and_breaks_down_sources(tmp_path): from dsagt.commands.info import _kb_collections kb = tmp_path / "kb_index" - (kb / "tools").mkdir(parents=True) + (kb / "codes").mkdir(parents=True) (kb / "skills").mkdir(parents=True) (kb / "research").mkdir(parents=True) # Tools: 2 bundled, 1 project for _ in range(2): - _write_chunk(kb / "tools" / "chunks.jsonl", source="bundled") - _write_chunk(kb / "tools" / "chunks.jsonl", source="project") + _write_chunk(kb / "codes" / "chunks.jsonl", source="bundled") + _write_chunk(kb / "codes" / "chunks.jsonl", source="project") # Skills: 1 bundled _write_chunk(kb / "skills" / "chunks.jsonl", source="bundled") @@ -390,8 +386,8 @@ def test_kb_collections_counts_chunks_and_breaks_down_sources(tmp_path): _write_chunk(kb / "research" / "chunks.jsonl") rows = {r["collection"]: r for r in _kb_collections(tmp_path)} - assert rows["tools"] == { - "collection": "tools", + assert rows["codes"] == { + "collection": "codes", "chunks": 3, "by_source": {"bundled": 2, "project": 1}, } @@ -490,7 +486,7 @@ def test_kb_retrieval_ignores_non_kb_spans(): "trace_metadata": {"mlflow.trace.session": "s"}, "spans": [ SimpleNamespace(name="kb.embed", attributes={}), - SimpleNamespace(name="registry.save_tool_spec", attributes={}), + SimpleNamespace(name="registry.save_code_spec", attributes={}), ], } ] diff --git a/tests/test_judge.py b/tests/test_judge.py deleted file mode 100644 index db81fb6..0000000 --- a/tests/test_judge.py +++ /dev/null @@ -1,124 +0,0 @@ -"""Tests for the episodic-memory Judge (Phase 3, Tier-1). - -The backend ``distill`` is a no-op for now; what's contractual today is the -factory, the lean per-turn prompt, and the tolerant response parser. -""" - -import pytest - -from dsagt.judge import ( - APIJudge, - Judge, - LocalJudge, - build_distill_prompt, - parse_distill_response, -) - -TAGS = { - "performance": "Runtime, memory usage, throughput", - "results": "Output summaries, key findings", -} - - -def test_create_selects_backends_and_defaults_local(): - assert isinstance(Judge.create("local"), LocalJudge) - assert isinstance(Judge.create("api"), APIJudge) - assert isinstance(Judge.create(""), LocalJudge) # default - with pytest.raises(ValueError): - Judge.create("nonesuch") - - -def test_local_judge_constructs_without_loading_weights(): - # No I/O at construction — the GGUF runtime loads lazily on first distill. - j = LocalJudge() - assert j.backend == "local" - assert j._llm is None - assert j.distill([], TAGS) == [] # no-op for now - - -def test_build_prompt_lists_tags_and_renders_turn(): - exchanges = [ - { - "new_messages": [ - {"role": "user", "content": [{"type": "text", "text": "filter reads"}]} - ], - "response": [{"type": "text", "text": "done, 90% passed QC"}], - } - ] - prompt = build_distill_prompt(exchanges, TAGS) - assert "performance" in prompt and "results" in prompt - assert "[user] filter reads" in prompt - assert "[assistant] done, 90% passed QC" in prompt - assert "[]" in prompt # the empty escape is advertised - - -def test_parse_validates_tags_and_strips_fences(): - raw = '```json\n[{"fact": "ran in 3s", "tag": "performance"}]\n```' - assert parse_distill_response(raw, TAGS) == [ - {"text": "ran in 3s", "tag": "performance"} - ] - - -def test_parse_drops_off_taxonomy_and_malformed(): - raw = ( - '[{"fact": "x", "tag": "made_up"}, ' # off-set tag → dropped - '{"fact": "", "tag": "results"}, ' # empty fact → dropped - '"not a dict", ' # junk → dropped - '{"fact": "kept", "tag": "results"}]' - ) - assert parse_distill_response(raw, TAGS) == [{"text": "kept", "tag": "results"}] - - -def test_parse_empty_escape(): - assert parse_distill_response("[]", TAGS) == [] - assert parse_distill_response("", TAGS) == [] - - -@pytest.mark.integration -def test_local_judge_distills_real_model(): - """End-to-end LocalJudge over the real GGUF (downloads ~1GB on first run). - - Deselected by default (``-m 'not integration'``); exercises grammar- - constrained inference: a fact-bearing turn yields well-formed, closed-set - facts, and a trivial turn hits the empty escape. - """ - from dsagt.memory import STOCK_CATEGORIES - - judge = LocalJudge() - fact_turn = [ - { - "new_messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Run fastp at a Q30 quality threshold.", - } - ], - } - ], - "response": [ - { - "type": "text", - "text": "Done — fastp filtered at Q30; 92% of reads passed.", - } - ], - } - ] - facts = judge.distill(fact_turn, STOCK_CATEGORIES) - assert isinstance(facts, list) and facts # deterministic (temp 0): non-empty - for f in facts: - assert set(f) == {"text", "tag"} - assert f["tag"] in STOCK_CATEGORIES # grammar-enforced closed set - assert f["text"].strip() - - trivial = [ - { - "new_messages": [ - {"role": "user", "content": [{"type": "text", "text": "thanks!"}]} - ], - "response": [{"type": "text", "text": "You're welcome."}], - } - ] - assert judge.distill(trivial, STOCK_CATEGORIES) == [] diff --git a/tests/test_kb_search_filters.py b/tests/test_kb_search_filters.py index 774604e..c9e7b4c 100644 --- a/tests/test_kb_search_filters.py +++ b/tests/test_kb_search_filters.py @@ -50,26 +50,6 @@ def server(mock_kb): class TestSearchFilterThreading: - def test_no_filters_no_where(self, server, mock_kb): - """Search without filter params passes where=None to kb.search.""" - call_tool( - server, - "kb_search", - { - "query": "test", - "collection": "docs", - }, - ) - - mock_kb.search.assert_called_once_with( - query="test", - collection="docs", - collections=None, - top_k=5, - rerank=None, - where=None, - ) - def test_tool_name_filter_passed(self, server, mock_kb): """tool_name filter is threaded through as where clause.""" call_tool( @@ -89,6 +69,7 @@ def test_tool_name_filter_passed(self, server, mock_kb): top_k=5, rerank=None, where={"tool_name": "fastp"}, + where_document=None, ) def test_session_filter_passed(self, server, mock_kb): @@ -110,6 +91,7 @@ def test_session_filter_passed(self, server, mock_kb): top_k=5, rerank=None, where={"session_id": "s3"}, + where_document=None, ) def test_multiple_filters_combined(self, server, mock_kb): @@ -125,10 +107,12 @@ def test_multiple_filters_combined(self, server, mock_kb): }, ) - call_kwargs = mock_kb.search.call_args[1] - assert "where" in call_kwargs - where = call_kwargs["where"] - assert "$and" in where + # The handler emits one single-key dict per filter, in the fixed + # source key order (category, session_id, source_type, tool_name) — + # so session_id precedes tool_name. Pin the exact payload: a dropped + # or duplicated filter must fail here. + where = mock_kb.search.call_args[1]["where"] + assert where == {"$and": [{"session_id": "s1"}, {"tool_name": "fastp"}]} def test_return_code_filter_passed(self, server, mock_kb): """return_code filter is threaded as integer.""" @@ -149,6 +133,7 @@ def test_return_code_filter_passed(self, server, mock_kb): top_k=5, rerank=None, where={"return_code": 1}, + where_document=None, ) def test_filters_with_multi_collection(self, server, mock_kb): @@ -175,7 +160,47 @@ def test_filters_with_multi_collection(self, server, mock_kb): top_k=5, rerank=None, where={"tool_name": "fastp"}, + where_document=None, + ) + + +class TestDocumentFilterThreading: + """The 'regex' / 'contains' args build a where_document passed to kb.search.""" + + def test_regex_threaded_as_where_document(self, server, mock_kb): + call_tool( + server, + "kb_search", + {"query": "q", "collection": "session_memory", "regex": "(?i)parser"}, + ) + assert mock_kb.search.call_args[1]["where_document"] == {"$regex": "(?i)parser"} + + def test_contains_threaded_as_where_document(self, server, mock_kb): + call_tool( + server, + "kb_search", + {"query": "q", "collection": "session_memory", "contains": "Q30"}, ) + assert mock_kb.search.call_args[1]["where_document"] == {"$contains": "Q30"} + + def test_regex_and_contains_combined_with_and(self, server, mock_kb): + call_tool( + server, + "kb_search", + { + "query": "q", + "collection": "session_memory", + "regex": "fastp", + "contains": "Q30", + }, + ) + assert mock_kb.search.call_args[1]["where_document"] == { + "$and": [{"$regex": "fastp"}, {"$contains": "Q30"}] + } + + def test_no_document_filter_is_none(self, server, mock_kb): + call_tool(server, "kb_search", {"query": "q", "collection": "session_memory"}) + assert mock_kb.search.call_args[1]["where_document"] is None # --------------------------------------------------------------------------- @@ -278,6 +303,8 @@ def test_filter_params_in_schema(self, server): "tool_name", "source_type", "return_code", + "regex", + "contains", ): assert param in props, f"Missing filter param: {param}" diff --git a/tests/test_knowledge_integration.py b/tests/test_knowledge_integration.py index c5b65e0..e125c03 100755 --- a/tests/test_knowledge_integration.py +++ b/tests/test_knowledge_integration.py @@ -15,7 +15,6 @@ import asyncio import json -import os from pathlib import Path import pytest @@ -91,7 +90,7 @@ def kb_server(tmp_path, smoke_test_dir, embedding_config): result = kb.ingest(smoke_test_dir) assert result["chunks"] > 0, "Ingest produced no chunks" - server = create_knowledge_server(kb, use_rerank=False) + server = create_knowledge_server(kb) yield server kb.close() @@ -106,7 +105,7 @@ class TestIngestIntegration: def test_ingest_smoke_test_docs(self, tmp_path, smoke_test_dir, embedding_config): """Ingest smoke test documents through the MCP handler.""" kb = _make_kb(tmp_path, embedding_config) - server = create_knowledge_server(kb, use_rerank=False) + server = create_knowledge_server(kb) async def run(): initial, final = await call_tool_and_await_job( @@ -188,12 +187,15 @@ def test_search_result_format(self, kb_server): ) assert result["status"] == "ok" - if result["result_count"] > 0: - hit = result["results"][0] - assert "text" in hit - assert "score" in hit - assert "source_file" in hit - assert "chunk_index" in hit + # The smoke-test corpus is fixed and owned by this test, so a known + # query must return at least one hit — then the shape is checked + # unconditionally (a zero-hit regression must not pass vacuously). + assert result["result_count"] > 0 + hit = result["results"][0] + assert "text" in hit + assert "score" in hit + assert "source_file" in hit + assert "chunk_index" in hit # --------------------------------------------------------------------------- @@ -214,37 +216,21 @@ def test_list_after_ingest(self, kb_server): # --------------------------------------------------------------------------- -# Setup runtime KB with symlinks +# Setup runtime KB (copies base collections into the project runtime) # --------------------------------------------------------------------------- class TestSetupRuntimeKB: - def test_symlinks_base_collections( - self, tmp_path, smoke_test_dir, embedding_config - ): - """setup_runtime_kb symlinks base collections into runtime.""" - base_dir = tmp_path / "base_kb" - base_dir.mkdir() - kb = _make_kb(tmp_path, embedding_config) - # Point KB at base_dir for this test - kb.index_dir = base_dir - kb.index_dir.mkdir(exist_ok=True) - kb.ingest(smoke_test_dir) - kb.close() - - runtime_dir = tmp_path / "runtime" - runtime_kb_dir = setup_runtime_kb(base_dir, runtime_dir) - - knowledge_link = runtime_kb_dir / "knowledge" - assert knowledge_link.exists() - assert knowledge_link.is_symlink() - assert (knowledge_link / "chroma_ids.json").exists() + def test_runtime_search_after_setup(self, tmp_path, smoke_test_dir, embedding_config): + """End-to-end: a KB pointed at a runtime dir provisioned by + setup_runtime_kb can search the copied collections with real embeddings. - def test_runtime_search_via_symlink( - self, tmp_path, smoke_test_dir, embedding_config - ): - """A KB pointing at runtime symlinks can search successfully.""" + Copy-vs-symlink structure is unit-tested network-free in + test_knowledge_server.py::TestSetupRuntimeKb; this is the only place + that exercises a real embedding search *through* the provisioned + runtime KB, so it stays an integration test. + """ base_dir = tmp_path / "base_kb" base_dir.mkdir() kb = _make_kb(tmp_path, embedding_config) diff --git a/tests/test_knowledge_server.py b/tests/test_knowledge_server.py index cf2d94c..49924c8 100644 --- a/tests/test_knowledge_server.py +++ b/tests/test_knowledge_server.py @@ -679,7 +679,7 @@ def test_copies_collections(self, tmp_path): def test_copy_is_independent(self, tmp_path): """Mutating the base after copy does not affect the project copy.""" base = tmp_path / "base_index" - coll = base / "tools" + coll = base / "codes" coll.mkdir(parents=True) (coll / "chroma_ids.json").write_text("v1") @@ -690,7 +690,7 @@ def test_copy_is_independent(self, tmp_path): (coll / "chroma_ids.json").write_text("v2 newer") # Project copy stays at v1. - project_copy = runtime / "kb_index" / "tools" / "chroma_ids.json" + project_copy = runtime / "kb_index" / "codes" / "chroma_ids.json" assert project_copy.read_text() == "v1" def test_skips_non_collection_dirs(self, tmp_path): @@ -780,27 +780,6 @@ def test_rerank_default_from_kb(self, mock_kb): server = create_knowledge_server(mock_kb) assert self._get_rerank_default(server) is True - def test_search_omitted_rerank_passes_none(self, mock_kb): - """Omitting rerank passes None to kb.search, which resolves to - kb.default_rerank internally.""" - server = create_knowledge_server(mock_kb) - call_tool( - server, - "kb_search", - { - "query": "test", - "collection": "docs", - }, - ) - mock_kb.search.assert_called_once_with( - query="test", - collection="docs", - collections=None, - top_k=5, - rerank=None, - where=None, - ) - # --------------------------------------------------------------------------- # kb_search — multi-collection fan-out (moved from the former memory test file) @@ -809,20 +788,6 @@ def test_search_omitted_rerank_passes_none(self, mock_kb): class TestKbSearchMultiCollection: - def test_single_collection_backward_compat(self, server, mock_kb): - """Plain search with collection still works.""" - result = call_tool( - server, - "kb_search", - { - "query": "test", - "collection": "docs", - }, - ) - - assert result["status"] == "ok" - mock_kb.search.assert_called_once() - def test_multi_collection_fanout(self, server, mock_kb): """Multi-collection search delegates once to kb.search with collections=. diff --git a/tests/test_memory.py b/tests/test_memory.py index fdadf36..d09467d 100644 --- a/tests/test_memory.py +++ b/tests/test_memory.py @@ -232,17 +232,13 @@ def test_handles_empty_file(self, tmp_path): assert mem.count() == 0 def test_handles_corrupt_file(self, tmp_path): - """Corrupt YAML either returns empty list or raises YAMLError.""" + """Corrupt YAML fails fast — get_all surfaces the YAMLError, never + silently recovers to an empty list (which would hide data loss).""" mem = ExplicitMemory(runtime_dir=tmp_path) (tmp_path / ExplicitMemory.FILENAME).write_text("not: valid: yaml: [") - try: - result = mem.get_all() - # If it didn't raise, it should return an empty list (graceful recovery) - assert isinstance(result, list) - except yaml.YAMLError: - # Failing fast on corruption is also acceptable - pass + with pytest.raises(yaml.YAMLError): + mem.get_all() def test_file_is_human_readable(self, mem, tmp_path): mem.remember("readable fact", category="test") diff --git a/tests/test_memory_extractor.py b/tests/test_memory_extractor.py index 082c32f..a4ce3c7 100644 --- a/tests/test_memory_extractor.py +++ b/tests/test_memory_extractor.py @@ -1,12 +1,9 @@ """Tests for the episodic-memory subscriber (MemoryExtractor). -Tier-0 (mechanical, always) and Tier-1 (Judge, opt-in) write paths, and the -judge-failure → Tier-0 fallback. The KB is faked so these stay unit tests with -no embedding model. +The mechanical write path: per-block chunking with producer/tool/turn_id +metadata. The KB is faked so these stay unit tests with no embedding model. """ -import numpy as np - from dsagt.memory import SESSION_MEMORY_COLLECTION, MemoryExtractor from dsagt.traces import Trace @@ -15,12 +12,10 @@ class FakeKB: def __init__(self): self.calls = [] - def add_entries(self, *, texts, collection, metadatas, return_embeddings=False): + def add_entries(self, *, texts, collection, metadatas): self.calls.append( {"texts": texts, "collection": collection, "metadatas": metadatas} ) - if return_embeddings: - return {"embeddings": np.zeros((len(texts), 3), dtype=np.float32)} return {} @@ -41,58 +36,70 @@ def _one_turn_trace(): return trace -def test_tier0_indexes_turns_when_no_judge(tmp_path): - kb = FakeKB() - ext = MemoryExtractor(kb, runtime_dir=tmp_path, session_id="proj:s") - ext.write(_one_turn_trace()) - - assert len(kb.calls) == 1 - call = kb.calls[0] - assert call["collection"] == SESSION_MEMORY_COLLECTION - assert call["metadatas"][0]["source_type"] == "turn" - assert call["metadatas"][0]["tier"] == "0" - assert isinstance(call["metadatas"][0]["ts_epoch"], float) # for recency - assert "filter the reads" in call["texts"][0] - - -def test_tier1_stores_distilled_facts(tmp_path): - class StubJudge: - backend = "local" +def _tool_trace(): + """A two-turn trace: the assistant calls a tool, the result returns next turn.""" + trace = Trace("t", "proj:s", "claude", "proj") + trace.add_agent_root("r1", "conv", start_time=1.0, prompt="profile it") + trace.add_llm_span( + "r1-0", + parent_id="r1", + start_time=1.0, + end_time=None, + request=[ + {"role": "user", "content": [{"type": "text", "text": "profile sales.csv"}]} + ], + response=[{"type": "tool_use", "id": "t1", "name": "profile", "input": {}}], + ) + trace.add_llm_span( + "r1-1", + parent_id="r1", + start_time=2.0, + end_time=None, + request=[ + { + "role": "user", + "content": [ + {"type": "tool_result", "tool_use_id": "t1", "content": "1000 rows"} + ], + } + ], + response=[{"type": "text", "text": "The file has 1000 rows."}], + ) + return trace - def distill(self, exchanges, tags): - return [{"text": "QC pass rate was 90%", "tag": "quality_control"}] +def test_each_block_is_its_own_chunk_with_producer(tmp_path): kb = FakeKB() - ext = MemoryExtractor( - kb, runtime_dir=tmp_path, session_id="proj:s", judge=StubJudge() - ) + ext = MemoryExtractor(kb, runtime_dir=tmp_path, session_id="proj:s") ext.write(_one_turn_trace()) assert len(kb.calls) == 1 call = kb.calls[0] - assert call["texts"] == ["QC pass rate was 90%"] - assert call["metadatas"][0]["source_type"] == "fact" - assert call["metadatas"][0]["category"] == "quality_control" - assert call["metadatas"][0]["tier"] == "1" - assert isinstance(call["metadatas"][0]["ts_epoch"], float) # for recency - - -def test_judge_failure_falls_back_to_tier0(tmp_path): - class BoomJudge: - backend = "local" + assert call["collection"] == SESSION_MEMORY_COLLECTION + # The user question and the assistant answer are separate chunks/embeddings. + assert call["texts"] == ["filter the reads", "kept 90 percent after QC"] + metas = call["metadatas"] + assert [m["producer"] for m in metas] == ["user", "llm"] + assert all(m["turn_id"] == "r1-0" for m in metas) + assert all(m["source_type"] == "turn" for m in metas) + assert all(isinstance(m["ts_epoch"], float) for m in metas) # for recency - def distill(self, exchanges, tags): - raise RuntimeError("model OOM") +def test_tool_result_chunk_carries_producer_and_resolved_tool(tmp_path): kb = FakeKB() - ext = MemoryExtractor( - kb, runtime_dir=tmp_path, session_id="proj:s", judge=BoomJudge() - ) - ext.write(_one_turn_trace()) - - # Degraded to Tier-0 — data preserved, never blocked. - assert len(kb.calls) == 1 - assert kb.calls[0]["metadatas"][0]["tier"] == "0" + ext = MemoryExtractor(kb, runtime_dir=tmp_path, session_id="proj:s") + ext.write(_tool_trace()) + + texts = kb.calls[0]["texts"] + metas = kb.calls[0]["metadatas"] + # tool_use is not embedded as prose... + assert "profile" not in texts + # ...but the tool_result is a chunk tagged producer=tool with the name + # resolved across turns via tool_use_id. + i = texts.index("1000 rows") + assert metas[i]["producer"] == "tool" + assert metas[i]["tool_name"] == "profile" + assert metas[i]["turn_id"] == "r1-1" def test_empty_trace_writes_nothing(tmp_path): diff --git a/tests/test_observability.py b/tests/test_observability.py index 6450655..0a3e9ca 100644 --- a/tests/test_observability.py +++ b/tests/test_observability.py @@ -1,14 +1,20 @@ """ Tests for dsagt.observability — Stage 0. -These tests use OTel's InMemorySpanExporter so they don't require a real -collector. They cover: +These tests read spans back from the serverless MLflow trace store (a +per-test ``sqlite:////mlflow.db``) rather than from OTel's +InMemorySpanExporter — the live-span path now uses ``mlflow.start_span`` +directly and installs no OTel TracerProvider. They cover: -* init_tracing is a no-op without an endpoint -* init_tracing with an endpoint installs a tracer provider +* init_tracing is a no-op outside a dsagt project dir +* init_tracing points MLflow at the resolved store + experiment * @traced opens a span, captures args, sets duration_ms, records exceptions * obs.set / obs.event are no-ops outside a span and write attributes inside -* child_span nests under the active span +* child_span / typed helpers nest under the active span +* internal traces are tagged ``dsagt.source`` + ``mlflow.trace.session`` + +Each test gets its own fresh store, so the LAST trace in the store is the +operation under test — no clear-then-read dance is needed. """ from __future__ import annotations @@ -22,54 +28,48 @@ @pytest.fixture(autouse=True) -def _reset_tracing(monkeypatch): - """Reset module state and install an in-memory exporter for each test. +def _reset_tracing(monkeypatch, tmp_path): + """Point MLflow at a fresh per-test sqlite store and mark tracing live. - We bypass ``init_tracing`` (no MLflow endpoint to talk to in tests) and - install our own TracerProvider with an InMemorySpanExporter directly. + Each test gets its own store, so reading the LAST active trace always + yields the operation under test — no exporter to clear between calls. """ - from opentelemetry import trace - from opentelemetry.sdk.trace import TracerProvider - from opentelemetry.sdk.trace.export import SimpleSpanProcessor - from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( - InMemorySpanExporter, - ) + import mlflow - monkeypatch.setattr(obs_module, "_initialized", False) - monkeypatch.setattr(obs_module, "_tracer_provider", None) + mlflow.set_tracking_uri(f"sqlite:///{tmp_path}/mlflow.db") + mlflow.set_experiment("test") + + monkeypatch.setattr(obs_module, "_initialized", True) monkeypatch.setattr(obs_module, "_default_session_id", None) - exporter = InMemorySpanExporter() - provider = TracerProvider() - provider.add_span_processor(SimpleSpanProcessor(exporter)) - from opentelemetry.util._once import Once + yield - trace._TRACER_PROVIDER = None # type: ignore[attr-defined] - trace._TRACER_PROVIDER_SET_ONCE = Once() # type: ignore[attr-defined] - trace.set_tracer_provider(provider) - monkeypatch.setattr(obs_module, "_initialized", True) - yield exporter +def _last_trace(): + import mlflow + from mlflow import MlflowClient + + return MlflowClient().get_trace(mlflow.get_last_active_trace_id()) + - exporter.clear() +def _spans_by_name(_ignored=None): + return {s.name: s for s in _last_trace().data.spans} def test_init_tracing_outside_project_is_noop(monkeypatch): """Serverless + never-raise: when cwd isn't a dsagt project dir (no - ``dsagt_config.yaml`` with a ``project``), ``init_tracing`` logs and + ``.dsagt/config.yaml`` with a ``project``), ``init_tracing`` logs and no-ops rather than raising — one-shot tools / tests outside a project simply run untraced. The store itself never needs a server, so the only reason to skip is "not in a project", which must not be fatal.""" monkeypatch.setattr(obs_module, "_initialized", False) monkeypatch.delenv("MLFLOW_TRACKING_URI", raising=False) - # Repo root has no dsagt_config.yaml → find_project_config returns None. + # Repo root has no .dsagt/config.yaml → find_project_config returns None. init_tracing("test-service") # must not raise assert obs_module._initialized is False def test_traced_emits_span_with_args(_reset_tracing): - exporter = _reset_tracing - @traced("test.op", capture=["a", "b"]) def f(a, b, c=10): return a + b + c @@ -77,10 +77,9 @@ def f(a, b, c=10): result = f(1, 2, c=3) assert result == 6 - spans = exporter.get_finished_spans() - assert len(spans) == 1 - span = spans[0] - assert span.name == "test.op" + spans = _spans_by_name() + assert "test.op" in spans + span = spans["test.op"] assert span.attributes["a"] == 1 assert span.attributes["b"] == 2 assert "c" not in span.attributes # not in capture list @@ -88,20 +87,16 @@ def f(a, b, c=10): def test_traced_extract_return(_reset_tracing): - exporter = _reset_tracing - @traced("test.op", extract_return={"hits": len}) def search(): return ["a", "b", "c"] search() - span = exporter.get_finished_spans()[0] + span = _spans_by_name()["test.op"] assert span.attributes["hits"] == 3 def test_traced_records_exception(_reset_tracing): - exporter = _reset_tracing - @traced("test.boom") def boom(): raise ValueError("kaboom") @@ -109,9 +104,9 @@ def boom(): with pytest.raises(ValueError): boom() - span = exporter.get_finished_spans()[0] + span = _spans_by_name()["test.boom"] assert span.status.status_code.name == "ERROR" - # exception is recorded as a span event + # exception is recorded as a span event (MLflow auto-records it) assert any(e.name == "exception" for e in span.events) # duration is still set even on error path assert "duration_ms" in span.attributes @@ -125,8 +120,6 @@ def test_obs_set_outside_span_is_noop(): def test_obs_set_inside_span_writes_attribute(_reset_tracing): - exporter = _reset_tracing - @traced("test.op") def f(): obs.set("hits", 7) @@ -135,7 +128,7 @@ def f(): return None f() - span = exporter.get_finished_spans()[0] + span = _spans_by_name()["test.op"] assert span.attributes["hits"] == 7 assert span.attributes["foo"] == "bar" assert "skipped" not in span.attributes @@ -143,43 +136,68 @@ def f(): def test_child_span_nests(_reset_tracing): - exporter = _reset_tracing - @traced("test.parent") def parent(): with child_span("test.child", phase="embed"): pass parent() - spans = exporter.get_finished_spans() - # Children finish first, then the parent. - by_name = {s.name: s for s in spans} - child = by_name["test.child"] - parent_span = by_name["test.parent"] - assert child.parent is not None - assert child.parent.span_id == parent_span.context.span_id + spans = _spans_by_name() + child = spans["test.child"] + parent_span = spans["test.parent"] + assert child.parent_id is not None + assert child.parent_id == parent_span.span_id assert child.attributes["phase"] == "embed" -def test_session_id_stamped_via_metadata(_reset_tracing, monkeypatch): - """Session id flows through ``_metadata_stamper`` → MLflow's - ``InMemoryTraceManager``, not as a span attribute. We verify the - stamper is invoked with the reserved ``mlflow.trace.session`` key. +def test_root_span_source_tags_trace_and_session(_reset_tracing, monkeypatch): + """A categorization root (``open_span(source=...)``) tags the trace's + ``dsagt.source`` and, when a session id is set, the reserved + ``mlflow.trace.session`` metadata key (the native session filter). """ - del _reset_tracing - captured: list[dict] = [] monkeypatch.setattr(obs_module, "_default_session_id", "proj-xyz") - monkeypatch.setattr(obs_module, "_metadata_stamper", captured.append) - @traced("test.op") + with obs_module.open_span("search_knowledge", source="knowledge"): + pass + + trace = _last_trace() + assert trace.info.tags["dsagt.source"] == "knowledge" + assert trace.info.trace_metadata["mlflow.trace.session"] == "proj-xyz" + + +def test_inner_spans_inherit_root_source(_reset_tracing): + """Source is set at the entry point, not derived from the span name: a + child span opened under a ``skill`` root inherits ``skill`` even when the + child is a ``kb.*`` (knowledge-subsystem) span. This is what makes + ``search_skills`` → ``kb.search`` tag as ``skill``, not ``knowledge``. + """ + with obs_module.open_span("search_skills", source="skill"): + + @traced("kb.search") + def inner(): + pass + + inner() + + trace = _last_trace() + assert trace.info.tags["dsagt.source"] == "skill" + # Both the root and the kb.search child live in the one trace. + names = {s.name for s in trace.data.spans} + assert {"search_skills", "kb.search"} <= names + + +def test_uncategorized_span_has_no_source(_reset_tracing): + """A span opened with no source (inner span outside any root, e.g. a + background ``kb.*`` write) carries no ``dsagt.source`` — it doesn't leak + into the debug-view filter as a miscategorized concern. + """ + + @traced("kb.add_entries") def f(): pass f() - # _attach_trace_metadata should have stamped session id (and source, - # if a prefix matched — "test.op" doesn't, so just the session). - found = [md for md in captured if "mlflow.trace.session" in md] - assert any(md["mlflow.trace.session"] == "proj-xyz" for md in found) + assert "dsagt.source" not in _last_trace().info.tags def test_init_tracing_double_call_only_updates_session(monkeypatch): @@ -190,39 +208,25 @@ def test_init_tracing_double_call_only_updates_session(monkeypatch): assert obs_module._default_session_id == "new" -def test_init_tracing_installs_mlflow_provider(monkeypatch): - """init_tracing installs MLflow's native tracer provider as the OTel - global, so every span flows into MLflow's trace store with full - ``mlflow.spanInputs`` / ``mlflow.spanOutputs`` integration. Pins the - set_experiment call (so the project's experiment exists) and verifies - the strategy pointer (_metadata_stamper) gets bound. +def test_init_tracing_points_mlflow_at_store_and_experiment(monkeypatch): + """init_tracing resolves the project name from ``.dsagt/config.yaml``, + points MLflow's tracking URI at the passed store, sets the experiment to + the project name, and flips ``_initialized`` true. """ captured: dict = {} - class _FakeExperiment: - experiment_id = "42" - def _fake_set_experiment(name): - captured["experiment_name"] = name - return _FakeExperiment() + captured["experiment"] = name def _fake_set_tracking_uri(uri): captured["tracking_uri"] = uri - def _fake_install(mlflow_url, project_name): - captured["installed_url"] = mlflow_url - captured["installed_project"] = project_name - import mlflow monkeypatch.setattr(mlflow, "set_experiment", _fake_set_experiment) monkeypatch.setattr(mlflow, "set_tracking_uri", _fake_set_tracking_uri) - # Stub the actual provider install — it touches OTel internals that - # aren't safely mockable in a unit test (Once flag, lazy provider). - monkeypatch.setattr(obs_module, "_install_mlflow_provider", _fake_install) monkeypatch.setattr(obs_module, "_initialized", False) - monkeypatch.setattr(obs_module, "_metadata_stamper", None) monkeypatch.setattr( obs_module, "find_project_config", @@ -230,30 +234,25 @@ def _fake_install(mlflow_url, project_name): ) try: - init_tracing("dsagt-run", mlflow_url="http://localhost:5000/") + init_tracing("dsagt-run", mlflow_url="sqlite:///x.db") + assert captured["tracking_uri"] == "sqlite:///x.db" + assert captured["experiment"] == "my-project" + assert obs_module._initialized is True finally: monkeypatch.setattr(obs_module, "_initialized", False) - assert captured["installed_url"] == "http://localhost:5000/" - assert captured["installed_project"] == "my-project" - # Strategy pointer should be bound to the MLflow-backed implementation. - assert obs_module._metadata_stamper is obs_module._stamp_metadata_mlflow - # --------------------------------------------------------------------------- # Safety nets for the remaining defensive catches in observability.py. # -# After the fallback-purge pass, three "soft" catches remain in the -# observability layer. Two of them now live inline inside traced()'s -# wrapper (previously _attach_captured_args and _attach_return_attrs): +# After the fallback-purge pass, two "soft" catches remain in the +# observability layer, both inline inside traced()'s wrapper (previously +# _attach_captured_args and _attach_return_attrs): # -# 1. _shutdown wraps tracer_provider.shutdown() in try/except so that -# a shutdown failure during process exit can't escape into the -# atexit chain and corrupt the parent process exit. -# 2. traced() wraps sig.bind_partial in except TypeError so that a +# 1. traced() wraps sig.bind_partial in except TypeError so that a # function whose signature was mangled by another decorator doesn't # crash on every traced call. -# 3. traced() wraps each user-supplied extractor lambda in except +# 2. traced() wraps each user-supplied extractor lambda in except # Exception so a buggy lambda doesn't crash the instrumented # function. # @@ -263,49 +262,6 @@ def _fake_install(mlflow_url, project_name): # --------------------------------------------------------------------------- -def test_shutdown_runs_cleanly_against_real_provider(monkeypatch): - """The _shutdown helper must successfully flush and shut down a real - BatchSpanProcessor-backed provider on the happy path. - - If the catch in _shutdown ever fires during dsagt-setup-kb's atexit - pass, the user loses any spans still buffered in the BatchSpanProcessor. - This test catches "shutdown raised an exception we silently swallowed" - by exercising the real shutdown path against a real (in-memory) provider. - """ - from opentelemetry.sdk.resources import Resource - from opentelemetry.sdk.trace import TracerProvider - from opentelemetry.sdk.trace.export import SimpleSpanProcessor - from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( - InMemorySpanExporter, - ) - - exporter = InMemorySpanExporter() - provider = TracerProvider( - resource=Resource.create({"service.name": "shutdown-test"}) - ) - provider.add_span_processor(SimpleSpanProcessor(exporter)) - - # Plug it into the module so _shutdown reaches the real object. - monkeypatch.setattr(obs_module, "_tracer_provider", provider) - - # Emit a span first so there's actually something to flush. - tracer = provider.get_tracer("test") - with tracer.start_as_current_span("pre-shutdown"): - pass - - # Run shutdown — must not raise, and must clear the module-level handle. - obs_module._shutdown() - assert obs_module._tracer_provider is None - - # The exporter should still hold the span we emitted before shutdown. - spans = exporter.get_finished_spans() - assert len(spans) == 1 - assert spans[0].name == "pre-shutdown" - - # Calling shutdown again is a no-op (idempotent atexit safety). - obs_module._shutdown() - - def test_extract_return_failure_logs_at_debug_and_does_not_crash( _reset_tracing, caplog ): @@ -334,9 +290,7 @@ def f(): assert result == ["a", "b", "c"] # Span was emitted with the good attributes set, bad one missing. - spans = _reset_tracing.get_finished_spans() - assert len(spans) == 1 - span = spans[0] + span = _spans_by_name()["test.extractor_bug"] assert span.attributes["good"] == 3 assert span.attributes["another_good"] == "a" assert "bad" not in span.attributes @@ -345,16 +299,16 @@ def f(): # what makes the silent skip visible to a developer running with # --verbose / DEBUG logging. debug_messages = [r.message for r in caplog.records if r.levelno == _logging.DEBUG] - assert any("extract_return['bad']" in m or "'bad'" in m for m in debug_messages), ( + assert any("extract_return['bad']" in m for m in debug_messages), ( f"Expected a DEBUG log mentioning the broken 'bad' extractor, " f"got: {debug_messages}" ) def test_attach_captured_args_happy_path_protects_bind_partial_catch(_reset_tracing): - """Pin the happy path for _attach_captured_args so the silent - 'except TypeError: return' catch can't hide a regression where args - stop being captured due to a signature-introspection bug. + """Pin the happy path for arg capture so the silent 'except TypeError: + skip' catch can't hide a regression where args stop being captured due to + a signature-introspection bug. If sig.bind_partial silently failed for any reason, this test would fail because the captured 'a' and 'b' attributes would be missing @@ -366,7 +320,7 @@ def f(a, b, c=42, *, d=None): return None f(1, b=2, d="ignored") - span = _reset_tracing.get_finished_spans()[0] + span = _spans_by_name()["test.signature_capture"] assert span.attributes["a"] == 1 assert span.attributes["b"] == 2 assert span.attributes["c"] == 42 # default value still captured @@ -378,10 +332,6 @@ def f(a, b, c=42, *, d=None): # --------------------------------------------------------------------------- -def _spans_by_name(exporter): - return {s.name: s for s in exporter.get_finished_spans()} - - @contextmanager def _kb_with_mocked_embedder(tmp_path, backend: str = "api", model: str = "test-model"): """Build a KnowledgeBase with a mocked embedder for the given backend. @@ -415,16 +365,15 @@ def fake_embed(texts): def test_kb_search_emits_three_child_spans(_reset_tracing, tmp_path): """kb.search should produce kb.search → {kb.embed, kb.index_search}.""" - exporter = _reset_tracing with _kb_with_mocked_embedder(tmp_path) as kb: # Seed a collection so search has something to load. kb.add_entries(texts=["hello world", "goodbye"], collection="tcoll") - exporter.clear() # ignore add_entries spans for this assertion results = kb.search("hello", collection="tcoll", top_k=2, rerank=False) assert isinstance(results, list) - spans = _spans_by_name(exporter) + # The last trace is the search (add_entries is an earlier trace). + spans = _spans_by_name() assert "kb.search" in spans assert "kb.embed" in spans assert "kb.index_search" in spans @@ -435,8 +384,8 @@ def test_kb_search_emits_three_child_spans(_reset_tracing, tmp_path): embed = spans["kb.embed"] index = spans["kb.index_search"] - assert embed.parent.span_id == parent.context.span_id - assert index.parent.span_id == parent.context.span_id + assert embed.parent_id == parent.span_id + assert index.parent_id == parent.span_id # Captured args + obs.set('hits', ...) on the parent. assert parent.attributes["collection"] == "tcoll" @@ -455,13 +404,11 @@ def test_kb_search_emits_three_child_spans(_reset_tracing, tmp_path): def test_kb_search_local_backend_same_span_shape(_reset_tracing, tmp_path): """The local embedding backend should emit the same span tree.""" - exporter = _reset_tracing with _kb_with_mocked_embedder(tmp_path, backend="local", model="bge-base") as kb: kb.add_entries(texts=["hello world"], collection="tcoll") - exporter.clear() kb.search("hello", collection="tcoll", top_k=1, rerank=False) - spans = _spans_by_name(exporter) + spans = _spans_by_name() assert "kb.search" in spans assert "kb.embed" in spans assert spans["kb.embed"].attributes["backend"] == "local" @@ -470,7 +417,6 @@ def test_kb_search_local_backend_same_span_shape(_reset_tracing, tmp_path): def test_kb_ingest_emits_embed_child(_reset_tracing, tmp_path): """kb.ingest opens an outer span and one child kb.embed span.""" - exporter = _reset_tracing src = tmp_path / "docs" src.mkdir() (src / "a.txt").write_text("alpha beta gamma") @@ -479,13 +425,13 @@ def test_kb_ingest_emits_embed_child(_reset_tracing, tmp_path): with _kb_with_mocked_embedder(tmp_path) as kb: kb.ingest(src) - spans = _spans_by_name(exporter) + spans = _spans_by_name() assert "kb.ingest" in spans assert "kb.embed" in spans ingest = spans["kb.ingest"] embed = spans["kb.embed"] - assert embed.parent.span_id == ingest.context.span_id + assert embed.parent_id == ingest.span_id assert ingest.attributes["n_files"] == 2 assert ingest.attributes["n_chunks"] >= 2 assert embed.attributes["n_texts"] == ingest.attributes["n_chunks"] @@ -493,11 +439,10 @@ def test_kb_ingest_emits_embed_child(_reset_tracing, tmp_path): def test_kb_add_entries_emits_span(_reset_tracing, tmp_path): """kb.add_entries should emit a top-level span with n_entries.""" - exporter = _reset_tracing with _kb_with_mocked_embedder(tmp_path) as kb: kb.add_entries(texts=["one", "two", "three"], collection="epis") - spans = _spans_by_name(exporter) + spans = _spans_by_name() assert "kb.add_entries" in spans assert spans["kb.add_entries"].attributes["collection"] == "epis" assert spans["kb.add_entries"].attributes["n_entries"] == 3 @@ -530,34 +475,30 @@ def test_truncate_handles_none(): assert truncate(None, 256) == "" -def test_tool_execute_span_attributes(_reset_tracing): - """tool_execute_span sets record_id and tool_name on the span.""" - exporter = _reset_tracing - - from dsagt.observability import obs, tool_execute_span +def test_code_execute_span_attributes(_reset_tracing): + """code_execute_span sets record_id and code_name on the span.""" + from dsagt.observability import obs, code_execute_span - with tool_execute_span(record_id="abc123", tool_name="fastp"): + with code_execute_span(record_id="abc123", code_name="fastp"): obs.set("exit_code", 0) obs.set("duration_ms", 42.5) - spans = _spans_by_name(exporter) - assert "tool.execute" in spans - span = spans["tool.execute"] + spans = _spans_by_name() + assert "code.execute" in spans + span = spans["code.execute"] assert span.attributes["record_id"] == "abc123" - assert span.attributes["tool_name"] == "fastp" + assert span.attributes["code_name"] == "fastp" assert span.attributes["exit_code"] == 0 assert span.attributes["duration_ms"] == 42.5 -def test_run_and_record_emits_tool_execute_span(_reset_tracing, tmp_path): +def test_run_and_record_emits_code_execute_span(_reset_tracing, tmp_path): """run_and_record() should produce a tool.execute span with the expected execution attributes.""" - exporter = _reset_tracing - from dsagt.provenance import run_and_record rc = run_and_record( - tool_name="echo", + code_name="echo", command=["echo", "hello world"], records_dir=tmp_path / "records", session_id="test-session", @@ -567,12 +508,12 @@ def test_run_and_record_emits_tool_execute_span(_reset_tracing, tmp_path): ) assert rc == 0 - spans = _spans_by_name(exporter) - assert "tool.execute" in spans - span = spans["tool.execute"] + spans = _spans_by_name() + assert "code.execute" in spans + span = spans["code.execute"] assert span.attributes["record_id"] == "rec-001" - assert span.attributes["tool_name"] == "echo" + assert span.attributes["code_name"] == "echo" assert span.attributes["exit_code"] == 0 assert span.attributes["duration_ms"] >= 0 assert span.attributes["n_input_files"] == 1 @@ -582,48 +523,42 @@ def test_run_and_record_emits_tool_execute_span(_reset_tracing, tmp_path): def test_run_and_record_failed_tool_records_event_and_status(_reset_tracing, tmp_path): - """A failed tool call should emit a tool_failed event with the exit code + """A failed tool call should emit a code_failed event with the exit code and the truncated stderr should be attached.""" - exporter = _reset_tracing - from dsagt.provenance import run_and_record rc = run_and_record( - tool_name="missing_tool", + code_name="missing_tool", command=["this-binary-does-not-exist-anywhere"], records_dir=tmp_path / "records", session_id="test-session", ) assert rc == 127 - spans = _spans_by_name(exporter) - span = spans["tool.execute"] + span = _spans_by_name()["code.execute"] assert span.attributes["exit_code"] == 127 # Stderr was set by the FileNotFoundError branch — should be on the span. assert "stderr_truncated" in span.attributes - # tool_failed event was added. - assert any(e.name == "tool_failed" for e in span.events) + # code_failed event was added. + assert any(e.name == "code_failed" for e in span.events) def test_run_and_record_long_stderr_is_truncated(_reset_tracing, tmp_path): """Span attributes should never carry multi-megabyte stderr blobs.""" - exporter = _reset_tracing - from dsagt.provenance import run_and_record # Use python -c to emit a large stderr deterministically. big = "x" * 5000 rc = run_and_record( - tool_name="echo_err", + code_name="echo_err", command=["python", "-c", f"import sys; sys.stderr.write('{big}'); sys.exit(0)"], records_dir=tmp_path / "records", session_id="s", ) assert rc == 0 - spans = _spans_by_name(exporter) - span = spans["tool.execute"] + span = _spans_by_name()["code.execute"] truncated = span.attributes["stderr_truncated"] # Truncated to ~256 chars even though stderr was 5000. assert len(truncated) < 300 @@ -642,11 +577,11 @@ def _make_registry_server(tmp_path): the real call_tool dispatcher rather than reaching into private state. """ from dsagt.mcp.registry_tools import create_registry_server - from dsagt.registry import ToolRegistry + from dsagt.registry import CodeRegistry source_dir = tmp_path / "source_skills" source_dir.mkdir() - reg = ToolRegistry( + reg = CodeRegistry( source_tools_dir=str(source_dir), runtime_dir=str(tmp_path / "runtime"), ) @@ -664,32 +599,33 @@ def _minimal_spec(name: str, **extras) -> dict: return spec -def test_save_tool_spec_emits_registry_save_span(_reset_tracing, tmp_path): - """save_tool_spec should produce a registry.save_tool_spec span with - tool_name, language, n_dependencies, n_tags, action, and registry_size.""" +def test_save_code_spec_emits_registry_save_span(_reset_tracing, tmp_path): + """save_code_spec should produce a registry.save_code_spec span with + code_name, language, n_dependencies, n_tags, action, and registry_size.""" from mcp_helpers import call_tool_sync as call_tool - exporter = _reset_tracing server = _make_registry_server(tmp_path) spec = _minimal_spec("alpha", language="python", tags=["genomics", "qc"]) - call_tool(server, "save_tool_spec", {"spec": spec}) + call_tool(server, "save_code_spec", {"spec": spec}) - spans = _spans_by_name(exporter) - assert "registry.save_tool_spec" in spans - span = spans["registry.save_tool_spec"] - assert span.attributes["tool_name"] == "alpha" + spans = _spans_by_name() + assert "registry.save_code_spec" in spans + span = spans["registry.save_code_spec"] + assert span.attributes["code_name"] == "alpha" assert span.attributes["language"] == "python" assert span.attributes["n_dependencies"] == 0 assert span.attributes["n_tags"] == 2 assert span.attributes["action"] == "added" assert span.attributes["registry_size"] == 1 + # The dispatch root tags the whole trace with the tool's concern category. + assert _last_trace().info.tags["dsagt.source"] == "registry" -def test_save_tool_spec_with_deps_nests_install_span( +def test_save_code_spec_with_deps_nests_install_span( _reset_tracing, tmp_path, monkeypatch ): - """When a spec carries dependencies, save_tool_spec should open a + """When a spec carries dependencies, save_code_spec should open a nested registry.install_dependencies span as a child.""" from mcp_helpers import call_tool_sync as call_tool @@ -702,17 +638,16 @@ def test_save_tool_spec_with_deps_nests_install_span( lambda packages, timeout=120: f"Successfully installed: {', '.join(packages)}", ) - exporter = _reset_tracing server = _make_registry_server(tmp_path) spec = _minimal_spec("beta", dependencies=["numpy", "pandas"]) - call_tool(server, "save_tool_spec", {"spec": spec}) + call_tool(server, "save_code_spec", {"spec": spec}) - spans = _spans_by_name(exporter) - save_span = spans["registry.save_tool_spec"] + spans = _spans_by_name() + save_span = spans["registry.save_code_spec"] install_span = spans["registry.install_dependencies"] - assert install_span.parent.span_id == save_span.context.span_id + assert install_span.parent_id == save_span.span_id assert install_span.attributes["package_count"] == 2 assert install_span.attributes["status"] == "ok" assert "numpy" in install_span.attributes["packages_preview"] @@ -733,17 +668,16 @@ def test_install_dependencies_failed_records_event( lambda packages, timeout=120: "Installation failed (exit code 1):\nresolution failure", ) - exporter = _reset_tracing server = _make_registry_server(tmp_path) # First register a tool with deps so install_dependencies has something # to operate on, then call install_dependencies directly. spec = _minimal_spec("gamma", dependencies=["broken-package"]) - call_tool(server, "save_tool_spec", {"spec": spec}) - exporter.clear() + call_tool(server, "save_code_spec", {"spec": spec}) call_tool(server, "install_dependencies", {}) - spans = _spans_by_name(exporter) + # The last trace is the install_dependencies call. + spans = _spans_by_name() assert "registry.install_dependencies" in spans span = spans["registry.install_dependencies"] assert span.attributes["package_count"] == 1 @@ -755,7 +689,6 @@ def test_reconstruct_pipeline_emits_span(_reset_tracing, tmp_path): """reconstruct_pipeline should produce a span with format and output_chars.""" from mcp_helpers import call_tool_sync as call_tool - exporter = _reset_tracing server = _make_registry_server(tmp_path) # Empty trace_archive — reconstruct_pipeline should still emit a span, @@ -764,21 +697,27 @@ def test_reconstruct_pipeline_emits_span(_reset_tracing, tmp_path): call_tool(server, "reconstruct_pipeline", {"format": "bash"}) - spans = _spans_by_name(exporter) + spans = _spans_by_name() assert "registry.reconstruct_pipeline" in spans span = spans["registry.reconstruct_pipeline"] assert span.attributes["format"] == "bash" assert "output_chars" in span.attributes -def test_search_registry_does_not_emit_span(_reset_tracing, tmp_path): - """High-frequency search calls are deliberately not instrumented.""" +def test_search_registry_categorized_but_no_internal_span(_reset_tracing, tmp_path): + """Every MCP call gets a categorized dispatch root span (so the concern + shows up in the debug view), but high-frequency search still adds no + internal subsystem span — the trace is just the root, tagged ``registry``, + with no ``registry.*`` child.""" from mcp_helpers import call_tool_sync as call_tool - exporter = _reset_tracing server = _make_registry_server(tmp_path) call_tool(server, "search_registry", {"query": "anything"}) - spans = _spans_by_name(exporter) - assert "registry.search" not in spans - assert "registry.search_registry" not in spans + trace = _last_trace() + names = {s.name for s in trace.data.spans} + # The dispatch root exists and is categorized... + assert "search_registry" in names + assert trace.info.tags["dsagt.source"] == "registry" + # ...but search opens no internal subsystem span. + assert not any(n.startswith("registry.") for n in names) diff --git a/tests/test_outlier.py b/tests/test_outlier.py deleted file mode 100644 index 3706144..0000000 --- a/tests/test_outlier.py +++ /dev/null @@ -1,251 +0,0 @@ -""" -Tests for outlier detection and suggestion queue. - -Tests CategoryCentroids (incremental update, cosine distance, persistence), -SuggestionQueue (add, dismiss, persistence), and check_and_queue_outliers -(threshold behavior, first-in-category skip). -""" - -import numpy as np -import pytest - -from dsagt.memory import CategoryCentroids, SuggestionQueue, check_and_queue_outliers - -# --------------------------------------------------------------------------- -# CategoryCentroids -# --------------------------------------------------------------------------- - - -class TestCategoryCentroids: - - def test_first_entry_returns_zero_distance(self, tmp_path): - """First entry in a category has no centroid to compare against.""" - centroids = CategoryCentroids(tmp_path / "centroids.json") - vec = np.array([1.0, 0.0, 0.0]) - distance = centroids.update("quality_control", vec) - assert distance == 0.0 - assert centroids.count("quality_control") == 1 - - def test_identical_vectors_zero_distance(self, tmp_path): - """Identical vectors have zero distance.""" - centroids = CategoryCentroids(tmp_path / "centroids.json") - vec = np.array([1.0, 0.0, 0.0]) - centroids.update("qc", vec) - distance = centroids.update("qc", vec) - assert distance < 0.01 - - def test_orthogonal_vectors_high_distance(self, tmp_path): - """Orthogonal vectors have distance ~1.0.""" - centroids = CategoryCentroids(tmp_path / "centroids.json") - centroids.update("qc", np.array([1.0, 0.0, 0.0])) - distance = centroids.update("qc", np.array([0.0, 1.0, 0.0])) - assert distance > 0.9 - - def test_incremental_update(self, tmp_path): - """Centroid shifts toward new vectors incrementally.""" - centroids = CategoryCentroids(tmp_path / "centroids.json") - centroids.update("qc", np.array([1.0, 0.0, 0.0])) - centroids.update("qc", np.array([1.0, 0.0, 0.0])) - centroids.update("qc", np.array([1.0, 0.0, 0.0])) - - # Outlier vector — mostly in the y direction - distance = centroids.update("qc", np.array([0.0, 1.0, 0.0])) - assert distance > 0.8 - assert centroids.count("qc") == 4 - - def test_save_and_reload(self, tmp_path): - """Centroids persist across reloads.""" - path = tmp_path / "centroids.json" - c1 = CategoryCentroids(path) - c1.update("qc", np.array([1.0, 0.0])) - c1.update("qc", np.array([0.9, 0.1])) - c1.save() - - c2 = CategoryCentroids(path) - assert c2.count("qc") == 2 - assert "qc" in c2.categories - - def test_separate_categories(self, tmp_path): - """Different categories maintain independent centroids.""" - centroids = CategoryCentroids(tmp_path / "centroids.json") - centroids.update("qc", np.array([1.0, 0.0])) - centroids.update("config", np.array([0.0, 1.0])) - - assert centroids.count("qc") == 1 - assert centroids.count("config") == 1 - - -# --------------------------------------------------------------------------- -# SuggestionQueue -# --------------------------------------------------------------------------- - - -class TestSuggestionQueue: - - def test_add_returns_id(self, tmp_path): - queue = SuggestionQueue(tmp_path / "suggestions.json") - sid = queue.add("test fact", "qc", 0.45, "s1") - assert sid.startswith("sug_") - assert queue.count == 1 - - def test_get_all(self, tmp_path): - queue = SuggestionQueue(tmp_path / "suggestions.json") - queue.add("fact 1", "qc", 0.4) - queue.add("fact 2", "config", 0.5) - suggestions = queue.get_all() - assert len(suggestions) == 2 - assert suggestions[0]["text"] == "fact 1" - assert suggestions[1]["category"] == "config" - - def test_dismiss_removes(self, tmp_path): - queue = SuggestionQueue(tmp_path / "suggestions.json") - sid = queue.add("test fact", "qc", 0.45) - assert queue.dismiss(sid) is True - assert queue.count == 0 - - def test_dismiss_nonexistent(self, tmp_path): - queue = SuggestionQueue(tmp_path / "suggestions.json") - assert queue.dismiss("sug_nonexistent") is False - - def test_clear(self, tmp_path): - queue = SuggestionQueue(tmp_path / "suggestions.json") - queue.add("a", "qc", 0.3) - queue.add("b", "qc", 0.4) - removed = queue.clear() - assert removed == 2 - assert queue.count == 0 - - def test_persistence(self, tmp_path): - """Suggestions survive reload.""" - path = tmp_path / "suggestions.json" - q1 = SuggestionQueue(path) - q1.add("persisted fact", "qc", 0.5) - - q2 = SuggestionQueue(path) - assert q2.count == 1 - assert q2.get_all()[0]["text"] == "persisted fact" - - -# --------------------------------------------------------------------------- -# check_and_queue_outliers -# --------------------------------------------------------------------------- - - -class TestCheckAndQueueOutliers: - - def _make_embeddings(self, vectors): - return np.array(vectors, dtype=np.float32) - - def test_flags_outlier(self, tmp_path): - """A vector far from its category centroid gets flagged.""" - centroids = CategoryCentroids(tmp_path / "centroids.json") - queue = SuggestionQueue(tmp_path / "suggestions.json") - - # Seed the centroid with a few similar vectors - for _ in range(5): - centroids.update("qc", np.array([1.0, 0.0, 0.0])) - centroids.save() - - texts = ["outlier observation"] - categories = ["qc"] - embeddings = self._make_embeddings([[0.0, 1.0, 0.0]]) # orthogonal - - ids = check_and_queue_outliers( - texts, - categories, - embeddings, - centroids, - queue, - threshold=0.3, - session_id="s1", - ) - - assert len(ids) == 1 - assert queue.count == 1 - - def test_normal_fact_not_flagged(self, tmp_path): - """A vector close to its category centroid is not flagged.""" - centroids = CategoryCentroids(tmp_path / "centroids.json") - queue = SuggestionQueue(tmp_path / "suggestions.json") - - for _ in range(5): - centroids.update("qc", np.array([1.0, 0.0, 0.0])) - centroids.save() - - texts = ["normal observation"] - categories = ["qc"] - embeddings = self._make_embeddings([[0.98, 0.1, 0.0]]) # close - - ids = check_and_queue_outliers( - texts, - categories, - embeddings, - centroids, - queue, - threshold=0.3, - session_id="s1", - ) - - assert len(ids) == 0 - assert queue.count == 0 - - def test_first_in_category_not_flagged(self, tmp_path): - """First entry in a category cannot be an outlier (no centroid yet).""" - centroids = CategoryCentroids(tmp_path / "centroids.json") - queue = SuggestionQueue(tmp_path / "suggestions.json") - - texts = ["first observation"] - categories = ["new_category"] - embeddings = self._make_embeddings([[0.5, 0.5, 0.5]]) - - ids = check_and_queue_outliers( - texts, - categories, - embeddings, - centroids, - queue, - threshold=0.1, - session_id="s1", - ) - - assert len(ids) == 0 - - def test_empty_category_skipped(self, tmp_path): - """Facts with empty category are skipped.""" - centroids = CategoryCentroids(tmp_path / "centroids.json") - queue = SuggestionQueue(tmp_path / "suggestions.json") - - texts = ["uncategorized"] - categories = [""] - embeddings = self._make_embeddings([[1.0, 0.0, 0.0]]) - - ids = check_and_queue_outliers( - texts, - categories, - embeddings, - centroids, - queue, - threshold=0.1, - ) - - assert len(ids) == 0 - - def test_centroids_saved(self, tmp_path): - """Centroids are saved to disk after checking.""" - centroids = CategoryCentroids(tmp_path / "centroids.json") - queue = SuggestionQueue(tmp_path / "suggestions.json") - - texts = ["fact"] - categories = ["qc"] - embeddings = self._make_embeddings([[1.0, 0.0]]) - - check_and_queue_outliers( - texts, - categories, - embeddings, - centroids, - queue, - threshold=0.3, - ) - - assert (tmp_path / "centroids.json").exists() diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index d09eeb5..15633e6 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -23,14 +23,14 @@ def _write_record(trace_dir: Path, record: dict) -> Path: trace_dir.mkdir(parents=True, exist_ok=True) rid = record.get("record_id", "r0") - tool = record.get("tool_name", "tool") + tool = record.get("code_name", "tool") path = trace_dir / f"{tool}_{rid}.json" path.write_text(json.dumps(record)) return path def _make_record( - tool_name: str, + code_name: str, command: list[str], input_files: list[str] | None = None, output_files: list[str] | None = None, @@ -41,7 +41,7 @@ def _make_record( ) -> dict: return { "record_id": record_id, - "tool_name": tool_name, + "code_name": code_name, "session_id": session_id, "execution": { "exact_command": command, @@ -69,13 +69,13 @@ def test_loads_wrapper_records(self, tmp_path): records = load_pipeline_records(tmp_path) assert len(records) == 1 - assert records[0]["tool_name"] == "fastp" + assert records[0]["code_name"] == "fastp" def test_skips_proxy_only_records(self, tmp_path): """Records without execution layer are skipped.""" proxy_record = { "record_id": "r1", - "tool_name": "fastp", + "code_name": "fastp", "session_id": "s1", "intent": {"command": "fastp", "parameters": {}}, "execution": None, @@ -96,7 +96,7 @@ def test_filters_by_session(self, tmp_path): records = load_pipeline_records(tmp_path, session_id="s1") assert len(records) == 1 - assert records[0]["tool_name"] == "a" + assert records[0]["code_name"] == "a" def test_sorted_by_timestamp(self, tmp_path): _write_record( @@ -113,8 +113,8 @@ def test_sorted_by_timestamp(self, tmp_path): ) records = load_pipeline_records(tmp_path) - assert records[0]["tool_name"] == "early" - assert records[1]["tool_name"] == "late" + assert records[0]["code_name"] == "early" + assert records[1]["code_name"] == "late" def test_empty_directory(self, tmp_path): tmp_path.mkdir(exist_ok=True) @@ -315,8 +315,10 @@ def test_session_filter(self, tmp_path): ) script = reconstruct_pipeline(tmp_path, session_id="s1", fmt="bash") + # Only the s1 record survives the filter: exactly one step (tool "a"), + # the s2 record ("b") is excluded — so there is no second step. assert "Step 1: a" in script - assert "b" not in script.split("Step")[1] if "Step" in script else True + assert "Step 2:" not in script def test_full_pipeline_with_deps(self, tmp_path): """Three-step pipeline: fastp → megahit → quast.""" diff --git a/tests/test_registry.py b/tests/test_registry.py index 5d5f155..1a4775f 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -1,5 +1,5 @@ """ -Tests for ToolRegistry and SkillRegistry. +Tests for CodeRegistry and SkillRegistry. Covers tool listing, MCP schema conversion, tool lookup, tool file writing, dsagt-run wrapping, runtime isolation, and skill discovery. @@ -9,7 +9,7 @@ import yaml from dsagt.registry import ( - ToolRegistry, + CodeRegistry, SkillRegistry, _wrap_executable, _uv_run_prefix, @@ -94,21 +94,21 @@ def test_valid_yaml_still_uses_strict_path(self, tmp_path): } -def _write_tool(tools_dir, spec: dict) -> None: +def _write_tool(codes_dir, spec: dict) -> None: """Write a minimal skill file for the given spec dict.""" - path = tools_dir / f"{spec['name']}.md" + path = codes_dir / f"{spec['name']}.md" frontmatter = yaml.dump(spec, default_flow_style=False, sort_keys=False) path.write_text(f"---\n{frontmatter}---\n\n# {spec['name']}\n") -def make_registry(tmp_path, tools: list[dict]) -> ToolRegistry: - """Create a ToolRegistry with the given tool definitions.""" - tools_dir = tmp_path / "source_skills" - tools_dir.mkdir() +def make_registry(tmp_path, tools: list[dict]) -> CodeRegistry: + """Create a CodeRegistry with the given tool definitions.""" + codes_dir = tmp_path / "source_skills" + codes_dir.mkdir() for tool in tools: - _write_tool(tools_dir, tool) - return ToolRegistry( - source_tools_dir=str(tools_dir), + _write_tool(codes_dir, tool) + return CodeRegistry( + source_tools_dir=str(codes_dir), runtime_dir=str(tmp_path / "runtime"), ) @@ -126,7 +126,7 @@ def empty_registry(tmp_path): # --------------------------------------------------------------------------- -# list_tools +# list_codes # --------------------------------------------------------------------------- @@ -134,7 +134,7 @@ class TestListTools: def test_schema_structure(self, registry): """MCP schema has name, description, and well-formed inputSchema.""" - tools = registry.list_tools() + tools = registry.list_codes() assert len(tools) == 1 tool = tools[0] @@ -148,7 +148,7 @@ def test_schema_structure(self, registry): def test_required_vs_optional(self, registry): """Required params appear in 'required', optional ones don't.""" - tool = registry.list_tools()[0] + tool = registry.list_codes()[0] required = tool["inputSchema"]["required"] assert "input_file" in required @@ -157,7 +157,7 @@ def test_required_vs_optional(self, registry): def test_default_values_propagate(self, registry): """Default values from the skill file appear in the MCP schema.""" - tool = registry.list_tools()[0] + tool = registry.list_codes()[0] props = tool["inputSchema"]["properties"] assert props["threshold"]["default"] == 0.5 @@ -165,12 +165,12 @@ def test_default_values_propagate(self, registry): def test_empty_registry(self, empty_registry): """An empty skills directory gives an empty tool list.""" - assert empty_registry.list_tools() == [] + assert empty_registry.list_codes() == [] def test_multiple_tools(self, tmp_path): """Multiple tools are listed in alphabetical filename order.""" reg = make_registry(tmp_path, [TOOL_WITH_MIXED_PARAMS, TOOL_NO_PARAMS]) - tools = reg.list_tools() + tools = reg.list_codes() assert len(tools) == 2 names = {t["name"] for t in tools} @@ -179,14 +179,14 @@ def test_multiple_tools(self, tmp_path): def test_no_params_tool(self, tmp_path): """Tool with empty parameters gives empty properties and required.""" reg = make_registry(tmp_path, [TOOL_NO_PARAMS]) - tool = reg.list_tools()[0] + tool = reg.list_codes()[0] assert tool["inputSchema"]["properties"] == {} assert tool["inputSchema"]["required"] == [] # --------------------------------------------------------------------------- -# get_tool +# get_code # --------------------------------------------------------------------------- @@ -194,14 +194,14 @@ class TestGetTool: def test_found(self, registry): """Returns the raw tool definition for an existing tool.""" - tool = registry.get_tool("process") + tool = registry.get_code("process") assert tool is not None assert tool["name"] == "process" assert tool["executable"] == "python process.py" def test_not_found(self, registry): """Returns None for a nonexistent tool.""" - assert registry.get_tool("nonexistent") is None + assert registry.get_code("nonexistent") is None # --------------------------------------------------------------------------- @@ -215,10 +215,10 @@ def test_add_new_tool(self, empty_registry): """Saving a new tool creates a skill file with dsagt-run wrapping.""" empty_registry.save_tool(TOOL_NO_PARAMS) - tool = empty_registry.get_tool("ping") + tool = empty_registry.get_code("ping") assert tool is not None assert tool["name"] == "ping" - assert tool["executable"] == "dsagt-run --tool ping -- echo pong" + assert tool["executable"] == "dsagt-run --code ping -- echo pong" def test_wraps_executable_with_dsagt_run(self, empty_registry): """save_tool automatically wraps the executable with dsagt-run.""" @@ -230,8 +230,8 @@ def test_wraps_executable_with_dsagt_run(self, empty_registry): "parameters": {}, } ) - tool = empty_registry.get_tool("mytool") - assert tool["executable"] == "dsagt-run --tool mytool -- python mytool.py" + tool = empty_registry.get_code("mytool") + assert tool["executable"] == "dsagt-run --code mytool -- python mytool.py" def test_does_not_double_wrap(self, empty_registry): """If executable already has dsagt-run, don't wrap again.""" @@ -239,11 +239,11 @@ def test_does_not_double_wrap(self, empty_registry): { "name": "mytool", "description": "test", - "executable": "dsagt-run --tool mytool -- python mytool.py", + "executable": "dsagt-run --code mytool -- python mytool.py", "parameters": {}, } ) - tool = empty_registry.get_tool("mytool") + tool = empty_registry.get_code("mytool") assert tool["executable"].count("dsagt-run") == 1 def test_python_deps_use_uv_run(self, empty_registry): @@ -257,9 +257,9 @@ def test_python_deps_use_uv_run(self, empty_registry): "dependencies": ["pandas>=2.0", "numpy"], } ) - tool = empty_registry.get_tool("analyzer") + tool = empty_registry.get_code("analyzer") assert tool["executable"] == ( - "dsagt-run --tool analyzer -- uv run --with pandas>=2.0,numpy -- python analyzer.py" + "dsagt-run --code analyzer -- uv run --with pandas>=2.0,numpy -- python analyzer.py" ) def test_no_deps_no_uv_run(self, empty_registry): @@ -272,9 +272,9 @@ def test_no_deps_no_uv_run(self, empty_registry): "parameters": {}, } ) - tool = empty_registry.get_tool("simple") + tool = empty_registry.get_code("simple") assert "uv run" not in tool["executable"] - assert tool["executable"] == "dsagt-run --tool simple -- echo hi" + assert tool["executable"] == "dsagt-run --code simple -- echo hi" def test_add_returns_added(self, empty_registry): """save_tool returns 'added' for new tools.""" @@ -287,7 +287,7 @@ def test_update_returns_updated(self, empty_registry): def test_update_preserves_body(self, empty_registry): """Updating a tool preserves any hand-edited markdown body.""" - skill_path = empty_registry.tools_dir / "ping.md" + skill_path = empty_registry.codes_dir / "ping.md" spec = TOOL_NO_PARAMS fm = __import__("yaml").dump(spec, default_flow_style=False, sort_keys=False) skill_path.write_text(f"---\n{fm}---\n\n# Custom docs written by hand.\n") @@ -304,7 +304,7 @@ def test_update_overwrites_frontmatter(self, empty_registry): updated = {**TOOL_NO_PARAMS, "description": "New description"} empty_registry.save_tool(updated) - tool = empty_registry.get_tool("ping") + tool = empty_registry.get_code("ping") assert tool["description"] == "New description" @@ -322,7 +322,7 @@ def test_source_unchanged_after_init(self, tmp_path): _write_tool(source_dir, TOOL_NO_PARAMS) runtime_dir = tmp_path / "runtime" - reg = ToolRegistry( + reg = CodeRegistry( source_tools_dir=str(source_dir), runtime_dir=str(runtime_dir), ) @@ -336,7 +336,7 @@ def test_source_unchanged_after_init(self, tmp_path): assert source_files[0].stem == "ping" # Runtime should have both tools - assert len(reg.list_tools()) == 2 + assert len(reg.list_codes()) == 2 # --------------------------------------------------------------------------- @@ -349,12 +349,12 @@ class TestDefaultTools: def test_tools_directory_exists(self): """The package ships a tools directory.""" - assert ToolRegistry._PACKAGE_TOOLS_DIR.exists() - assert ToolRegistry._PACKAGE_TOOLS_DIR.is_dir() + assert CodeRegistry._PACKAGE_CODES_DIR.exists() + assert CodeRegistry._PACKAGE_CODES_DIR.is_dir() def test_tools_are_valid(self): """Every tool file must parse cleanly and have required fields.""" - tool_files = list(ToolRegistry._PACKAGE_TOOLS_DIR.glob("*.md")) + tool_files = list(CodeRegistry._PACKAGE_CODES_DIR.glob("*.md")) assert len(tool_files) > 0, "No tool files found in package" for path in tool_files: @@ -365,9 +365,9 @@ def test_tools_are_valid(self): assert "parameters" in tool, f"{path.name}: missing 'parameters'" def test_default_init_fallback(self, tmp_path): - """ToolRegistry with no source_tools_dir falls back to package skills.""" - reg = ToolRegistry(source_tools_dir=None, runtime_dir=str(tmp_path / "rt")) - tools = reg.list_tools() + """CodeRegistry with no source_tools_dir falls back to package skills.""" + reg = CodeRegistry(source_tools_dir=None, runtime_dir=str(tmp_path / "rt")) + tools = reg.list_codes() assert len(tools) > 0 names = [t["name"] for t in tools] assert "scan_directory" in names diff --git a/tests/test_registry_server.py b/tests/test_registry_server.py index 3af42c0..3d83b04 100644 --- a/tests/test_registry_server.py +++ b/tests/test_registry_server.py @@ -1,7 +1,7 @@ """ Tests for the registry MCP server. -Tests tool handlers: save_tool_spec, get_registry, search_registry, +Tests tool handlers: save_code_spec, get_registry, search_registry, read_file, run_command, install_dependencies. """ @@ -13,7 +13,7 @@ import pytest import yaml -from dsagt.registry import ToolRegistry +from dsagt.registry import CodeRegistry from dsagt.mcp.registry_tools import create_registry_server from mcp_helpers import call_tool_sync as call_tool @@ -42,8 +42,8 @@ def make_spec( return spec -def _write_tool(tools_dir: Path, spec: dict) -> None: - path = tools_dir / f"{spec['name']}.md" +def _write_tool(codes_dir: Path, spec: dict) -> None: + path = codes_dir / f"{spec['name']}.md" fm = yaml.dump(spec, default_flow_style=False, sort_keys=False) path.write_text(f"---\n{fm}---\n\n# {spec['name']}\n") @@ -60,11 +60,11 @@ def _make_server(tmp_path, tools=None): source_dir = tmp_path / "source_skills" source_dir.mkdir() runtime_dir = tmp_path / "runtime" - project_tools_dir = runtime_dir / "tools" + project_tools_dir = runtime_dir / "codes" project_tools_dir.mkdir(parents=True, exist_ok=True) for spec in tools or []: _write_tool(project_tools_dir, spec) - reg = ToolRegistry( + reg = CodeRegistry( source_tools_dir=str(source_dir), runtime_dir=str(runtime_dir), ) @@ -109,7 +109,7 @@ def populated_server(populated): # --------------------------------------------------------------------------- -# save_tool_spec +# save_code_spec # --------------------------------------------------------------------------- @@ -118,37 +118,37 @@ class TestSaveToolSpec: def test_add_new_tool(self, server, registry): """Saving a new spec creates a skill file and reports added.""" spec = make_spec("my_tool") - text = call_tool(server, "save_tool_spec", {"spec": spec}) + text = call_tool(server, "save_code_spec", {"spec": spec}) assert "added" in text assert "1 tools" in text - assert registry.get_tool("my_tool") is not None + assert registry.get_code("my_tool") is not None def test_update_existing_tool(self, server, registry): """Saving a spec with the same name updates rather than duplicates.""" call_tool( server, - "save_tool_spec", + "save_code_spec", {"spec": make_spec("my_tool", description="Version 1")}, ) text = call_tool( server, - "save_tool_spec", + "save_code_spec", {"spec": make_spec("my_tool", description="Version 2")}, ) assert "updated" in text assert "1 tools" in text - assert registry.get_tool("my_tool")["description"] == "Version 2" + assert registry.get_code("my_tool")["description"] == "Version 2" def test_add_multiple_tools(self, server, registry): """Multiple distinct tools accumulate as separate skill files.""" - call_tool(server, "save_tool_spec", {"spec": make_spec("tool_a")}) - text = call_tool(server, "save_tool_spec", {"spec": make_spec("tool_b")}) + call_tool(server, "save_code_spec", {"spec": make_spec("tool_a")}) + text = call_tool(server, "save_code_spec", {"spec": make_spec("tool_b")}) assert "2 tools" in text - assert registry.get_tool("tool_a") is not None - assert registry.get_tool("tool_b") is not None + assert registry.get_code("tool_a") is not None + assert registry.get_code("tool_b") is not None def test_accepts_stringified_spec(self, server, registry): """Some MCP clients (Claude Sonnet/Haiku 4.x) send nested-object args as @@ -156,14 +156,14 @@ def test_accepts_stringified_spec(self, server, registry): import json spec = make_spec("stringy_tool") - text = call_tool(server, "save_tool_spec", {"spec": json.dumps(spec)}) + text = call_tool(server, "save_code_spec", {"spec": json.dumps(spec)}) assert "added" in text - assert registry.get_tool("stringy_tool") is not None + assert registry.get_code("stringy_tool") is not None def test_rejects_invalid_stringified_spec(self, server, registry): """Non-JSON strings produce a clear error rather than crashing.""" - text = call_tool(server, "save_tool_spec", {"spec": "not valid json {"}) + text = call_tool(server, "save_code_spec", {"spec": "not valid json {"}) assert "Error" in text assert "JSON object" in text @@ -186,8 +186,8 @@ def test_populated_registry(self, populated_server, populated): text = call_tool(populated_server, "get_registry", {}) data = yaml.safe_load(text) - assert len(data["tools"]) == 2 - names = [t["name"] for t in data["tools"]] + assert len(data["codes"]) == 2 + names = [t["name"] for t in data["codes"]] assert "tool_alpha" in names assert "tool_beta" in names @@ -209,23 +209,29 @@ class TestSearchRegistryNoKB: """ def test_exact_name_lookup_works_without_kb(self, populated_server): - """tool_name lookup is KB-free and must keep working.""" + """code_name lookup is KB-free and must keep working.""" text = call_tool( - populated_server, "search_registry", {"tool_name": "tool_alpha"} + populated_server, "search_registry", {"code_name": "tool_alpha"} ) assert "tool_alpha" in text def test_exact_name_miss_without_kb(self, populated_server): - """tool_name with a non-existent name returns a clean 'no tool' message.""" + """code_name with a non-existent name returns a clean 'no tool' message.""" text = call_tool( - populated_server, "search_registry", {"tool_name": "nonexistent"} + populated_server, "search_registry", {"code_name": "nonexistent"} ) assert "No tool named 'nonexistent'" in text def test_query_search_without_kb_returns_helpful_error(self, populated_server): """A semantic search request when no KB is configured must surface - the missing-KB condition clearly, not silently degrade.""" + the missing-KB condition clearly, not silently degrade. + + The query "alpha" is a substring of the registered ``tool_alpha``; + the deleted string-matching fallback would have returned it, so the + ``not in`` assertion pins that the fallback stays gone. + """ text = call_tool(populated_server, "search_registry", {"query": "alpha"}) + assert "tool_alpha" not in text # no silent substring fallback assert "knowledge base" in text.lower() assert "embedding" in text.lower() @@ -302,7 +308,7 @@ def test_timeout(self, server): # --------------------------------------------------------------------------- -# save_tool_spec — dependency installation +# save_code_spec — dependency installation # --------------------------------------------------------------------------- @@ -315,7 +321,7 @@ def test_deps_installed_on_save(self, mock_run, server, registry): returncode=0, stdout="Successfully installed pandas-2.1.0", stderr="" ) spec = make_spec("tool_with_deps", dependencies=["pandas>=2.0", "numpy"]) - text = call_tool(server, "save_tool_spec", {"spec": spec}) + text = call_tool(server, "save_code_spec", {"spec": spec}) assert "added" in text assert "Successfully installed" in text @@ -338,11 +344,11 @@ def test_deps_failure_still_saves_spec(self, mock_run, server, registry): returncode=1, stdout="", stderr="No matching distribution for bogus-pkg" ) spec = make_spec("tool_bad_deps", dependencies=["bogus-pkg"]) - text = call_tool(server, "save_tool_spec", {"spec": spec}) + text = call_tool(server, "save_code_spec", {"spec": spec}) assert "added" in text assert "Installation failed" in text - tool = registry.get_tool("tool_bad_deps") + tool = registry.get_code("tool_bad_deps") assert tool is not None assert tool["dependencies"] == ["bogus-pkg"] @@ -351,7 +357,7 @@ def test_deps_timeout(self, mock_run, server): """Timeout during install is reported, spec is still saved.""" mock_run.side_effect = subprocess.TimeoutExpired("uv", 120) spec = make_spec("tool_slow_deps", dependencies=["heavy-pkg"]) - text = call_tool(server, "save_tool_spec", {"spec": spec}) + text = call_tool(server, "save_code_spec", {"spec": spec}) assert "added" in text assert "timed out" in text @@ -359,7 +365,7 @@ def test_deps_timeout(self, mock_run, server): def test_no_deps_no_install_message(self, server, registry): """When no dependencies are provided, no install message appears.""" spec = make_spec("tool_no_deps") - text = call_tool(server, "save_tool_spec", {"spec": spec}) + text = call_tool(server, "save_code_spec", {"spec": spec}) assert "added" in text assert "Dependency" not in text @@ -369,9 +375,9 @@ def test_deps_persisted_in_skill_file(self, mock_run, server, registry): """Dependencies are stored in the skill file frontmatter.""" mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="") spec = make_spec("dep_tool", dependencies=["requests>=2.28"]) - call_tool(server, "save_tool_spec", {"spec": spec}) + call_tool(server, "save_code_spec", {"spec": spec}) - tool = registry.get_tool("dep_tool") + tool = registry.get_code("dep_tool") assert tool["dependencies"] == ["requests>=2.28"] @patch("dsagt.mcp.registry_tools.subprocess.run") @@ -379,7 +385,7 @@ def test_uv_not_found(self, mock_run, server): """FileNotFoundError from missing uv is reported gracefully.""" mock_run.side_effect = FileNotFoundError("uv") spec = make_spec("tool_no_uv", dependencies=["pandas"]) - text = call_tool(server, "save_tool_spec", {"spec": spec}) + text = call_tool(server, "save_code_spec", {"spec": spec}) assert "added" in text assert "'uv' command not found" in text @@ -394,7 +400,7 @@ class TestInstallDependencies: @patch("dsagt.mcp.registry_tools.subprocess.run") def test_install_all(self, mock_run, tmp_path): - """install_dependencies with no tool_name installs all unique deps.""" + """install_dependencies with no code_name installs all unique deps.""" server, reg = _make_server( tmp_path, tools=[ @@ -422,7 +428,7 @@ def test_install_all(self, mock_run, tmp_path): @patch("dsagt.mcp.registry_tools.subprocess.run") def test_install_single_tool(self, mock_run, tmp_path): - """install_dependencies with tool_name targets only that tool.""" + """install_dependencies with code_name targets only that tool.""" server, reg = _make_server( tmp_path, tools=[ @@ -432,7 +438,7 @@ def test_install_single_tool(self, mock_run, tmp_path): ) mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="") - text = call_tool(server, "install_dependencies", {"tool_name": "tool_b"}) + text = call_tool(server, "install_dependencies", {"code_name": "tool_b"}) cmd = mock_run.call_args[0][0] assert cmd == ["uv", "pip", "install", "--python", sys.executable, "scipy"] @@ -468,7 +474,7 @@ def _make_server_with_kb(tmp_path, tools=None): source_dir = tmp_path / "source_skills" source_dir.mkdir() runtime_dir = tmp_path / "runtime" - project_tools_dir = runtime_dir / "tools" + project_tools_dir = runtime_dir / "codes" project_tools_dir.mkdir(parents=True, exist_ok=True) for spec in tools or []: _write_tool(project_tools_dir, spec) @@ -477,7 +483,7 @@ def _make_server_with_kb(tmp_path, tools=None): index_dir=tmp_path / "kb_index", default_embedder="local", ) - reg = ToolRegistry( + reg = CodeRegistry( source_tools_dir=str(source_dir), runtime_dir=str(runtime_dir), kb=kb, @@ -497,7 +503,7 @@ def test_save_tool_indexes_into_kb(self, tmp_path): call_tool( server, - "save_tool_spec", + "save_code_spec", { "spec": make_spec( name="csv_filter", @@ -510,27 +516,12 @@ def test_save_tool_indexes_into_kb(self, tmp_path): assert len(results) > 0 assert any("csv_filter" in r["chunk"].get("text", "") for r in results) - def test_search_registry_by_name(self, tmp_path): - """Exact tool_name lookup returns the tool.""" - server, reg, kb = _make_server_with_kb(tmp_path) - call_tool(server, "save_tool_spec", {"spec": make_spec(name="fastp")}) - - text = call_tool(server, "search_registry", {"tool_name": "fastp"}) - assert "fastp" in text - - def test_search_registry_by_name_not_found(self, tmp_path): - """Exact lookup for nonexistent tool returns not found.""" - server, reg, kb = _make_server_with_kb(tmp_path) - - text = call_tool(server, "search_registry", {"tool_name": "nonexistent"}) - assert "No tool" in text - def test_search_registry_semantic(self, tmp_path): """Semantic search finds tools by description similarity.""" server, reg, kb = _make_server_with_kb(tmp_path) call_tool( server, - "save_tool_spec", + "save_code_spec", { "spec": make_spec( name="csv_filter", @@ -550,11 +541,11 @@ def test_search_registry_by_tag(self, tmp_path): spec_genomics = make_spec(name="fastp", description="FASTQ preprocessor") spec_genomics["tags"] = ["genomics", "data_processing"] - call_tool(server, "save_tool_spec", {"spec": spec_genomics}) + call_tool(server, "save_code_spec", {"spec": spec_genomics}) spec_other = make_spec(name="csvtool", description="CSV processor") spec_other["tags"] = ["data_processing"] - call_tool(server, "save_tool_spec", {"spec": spec_other}) + call_tool(server, "save_code_spec", {"spec": spec_other}) text = call_tool( server, "search_registry", {"query": "tool", "tag": "genomics"} @@ -579,24 +570,3 @@ def test_reindex_all(self, tmp_path): results = kb.search("registered", collection=TOOL_REGISTRY_COLLECTION) assert len(results) > 0 - - def test_no_kb_query_search_returns_explicit_error(self, tmp_path): - """Without a configured KB, query-based search MUST NOT silently - fall back to substring matching. It must return an explicit - error so the user knows the KB is missing. - - Regression test for the deletion of the string-matching fallback - in search_registry. The old fallback hid embedding/KB failures - and produced dramatically worse search results without telling - anyone. - """ - server, reg = _make_server( - tmp_path, - tools=[ - make_spec(name="csv_filter", description="Filter CSV rows"), - ], - ) - - text = call_tool(server, "search_registry", {"query": "csv"}) - assert "csv_filter" not in text # the substring match must NOT happen - assert "knowledge base" in text.lower() diff --git a/tests/test_run.py b/tests/test_run.py index f32d7b0..c0be23d 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -16,7 +16,7 @@ _write_record, run_and_record, ) -from dsagt.commands.run_tool import ( +from dsagt.commands.run_code import ( _parse_args, main, ) @@ -29,14 +29,14 @@ class TestParseArgs: def test_basic(self): - args, command = _parse_args(["--tool", "fastp", "--", "fastp", "-q", "20"]) - assert args.tool == "fastp" + args, command = _parse_args(["--code", "fastp", "--", "fastp", "-q", "20"]) + assert args.code == "fastp" assert command == ["fastp", "-q", "20"] def test_all_flags(self): args, command = _parse_args( [ - "--tool", + "--code", "megahit", "--session", "sess-1", @@ -54,7 +54,7 @@ def test_all_flags(self): "a.fq", ] ) - assert args.tool == "megahit" + assert args.code == "megahit" assert args.session == "sess-1" assert args.record_id == "rec-42" assert args.records_dir == "/tmp/records" @@ -65,10 +65,10 @@ def test_all_flags(self): def test_no_separator_exits(self): """Missing '--' separator causes a SystemExit (from argparse --help).""" with pytest.raises(SystemExit): - _parse_args(["--tool", "fastp", "fastp", "-q", "20"]) + _parse_args(["--code", "fastp", "fastp", "-q", "20"]) def test_defaults(self): - args, _ = _parse_args(["--tool", "x", "--", "echo"]) + args, _ = _parse_args(["--code", "x", "--", "echo"]) assert args.session is None assert args.record_id is None assert args.records_dir is None @@ -137,7 +137,7 @@ def test_creates_directory_and_file(self, tmp_path): records_dir = tmp_path / "nested" / "records" record = { "record_id": "abc123", - "tool_name": "fastp", + "code_name": "fastp", "session_id": None, "execution": { "exact_command": ["fastp", "-q", "20"], @@ -158,13 +158,13 @@ def test_creates_directory_and_file(self, tmp_path): assert "abc123" in path.name data = json.loads(path.read_text()) - assert data["tool_name"] == "fastp" + assert data["code_name"] == "fastp" assert data["execution"]["return_code"] == 0 def test_record_is_valid_json(self, tmp_path): record = { "record_id": "x", - "tool_name": "t", + "code_name": "t", "session_id": None, "execution": { "exact_command": ["echo"], @@ -192,7 +192,7 @@ class TestRunAndRecord: def test_successful_command(self, tmp_path): """Runs echo, captures output, writes record, returns 0.""" exit_code = run_and_record( - tool_name="echo_test", + code_name="echo_test", command=["echo", "hello world"], records_dir=tmp_path, record_id="test-001", @@ -204,7 +204,7 @@ def test_successful_command(self, tmp_path): assert len(records) == 1 data = json.loads(records[0].read_text()) - assert data["tool_name"] == "echo_test" + assert data["code_name"] == "echo_test" assert data["record_id"] == "test-001" assert data["execution"]["return_code"] == 0 assert "hello world" in data["execution"]["stdout"] @@ -213,7 +213,7 @@ def test_successful_command(self, tmp_path): def test_failing_command(self, tmp_path): """A command that fails returns non-zero and captures stderr.""" exit_code = run_and_record( - tool_name="false_test", + code_name="false_test", command=["bash", "-c", "echo oops >&2; exit 42"], records_dir=tmp_path, record_id="test-002", @@ -228,7 +228,7 @@ def test_failing_command(self, tmp_path): def test_command_not_found(self, tmp_path): """A missing command returns exit code 127.""" exit_code = run_and_record( - tool_name="missing", + code_name="missing", command=["this_command_does_not_exist_xyz"], records_dir=tmp_path, record_id="test-003", @@ -250,7 +250,7 @@ def test_session_from_state(self, tmp_path, monkeypatch): append_session(tmp_path) # mints session id 1 → tag "t-1" monkeypatch.chdir(tmp_path) run_and_record( - tool_name="t", + code_name="t", command=["echo"], records_dir=tmp_path, record_id="test-004", @@ -267,7 +267,7 @@ def test_explicit_session_overrides_state(self, tmp_path, monkeypatch): append_session(tmp_path) monkeypatch.chdir(tmp_path) run_and_record( - tool_name="t", + code_name="t", command=["echo"], records_dir=tmp_path, session_id="explicit-session", @@ -280,7 +280,7 @@ def test_explicit_session_overrides_state(self, tmp_path, monkeypatch): def test_file_lists_recorded(self, tmp_path): """Input and output file lists appear in the record.""" run_and_record( - tool_name="t", + code_name="t", command=["echo"], records_dir=tmp_path, input_files=["in1.fq", "in2.fq"], @@ -295,7 +295,7 @@ def test_file_lists_recorded(self, tmp_path): def test_timestamps_are_populated(self, tmp_path): """Start and end timestamps are non-empty ISO strings.""" run_and_record( - tool_name="t", + code_name="t", command=["echo"], records_dir=tmp_path, record_id="test-007", @@ -311,7 +311,7 @@ def test_timestamps_are_populated(self, tmp_path): def test_auto_generates_record_id(self, tmp_path): """Omitting record_id auto-generates one.""" run_and_record( - tool_name="t", + code_name="t", command=["echo"], records_dir=tmp_path, ) @@ -358,7 +358,7 @@ def test_basic_invocation(self, tmp_path): """main() runs a command and returns its exit code.""" exit_code = main( [ - "--tool", + "--code", "echo_tool", "--records-dir", str(tmp_path), @@ -376,7 +376,7 @@ def test_empty_command_returns_1(self, tmp_path): """No command after '--' returns exit code 1.""" exit_code = main( [ - "--tool", + "--code", "empty", "--records-dir", str(tmp_path), @@ -389,7 +389,7 @@ def test_exit_code_propagation(self, tmp_path): """main() returns the wrapped command's exit code.""" exit_code = main( [ - "--tool", + "--code", "fail", "--records-dir", str(tmp_path), diff --git a/tests/test_server_startup.py b/tests/test_server_startup.py index 8c0b0f4..243d78d 100644 --- a/tests/test_server_startup.py +++ b/tests/test_server_startup.py @@ -5,24 +5,17 @@ to verify the entry point is wired and fails fast + clearly on a misconfigured project — without needing a live MLflow backend or network access. -The full boot (init_tracing → shared KB → 23-tool MCP handshake) requires a +The full boot (init_tracing → shared KB → 20-tool MCP handshake) requires a running MLflow server, so it is exercised by ``dsagt smoke-test`` (real agent), -not here. The 23-tool composition + dispatch contract is unit-tested in-process +not here. The 20-tool composition + dispatch contract is unit-tested in-process by ``test_dsagt_server.py``; ``_build_kb_from_config``'s credential validation by ``test_dsagt_server.py::TestBuildKbFromConfig``. """ -import shutil import sys -import pytest - from mcp_helpers import start_server -_uv_available = shutil.which("uv") is not None - -pytestmark = pytest.mark.skipif(not _uv_available, reason="uv not available") - _SERVER_CMD = [sys.executable, "-m", "dsagt.mcp.server"] diff --git a/tests/test_setup_core_kb.py b/tests/test_setup_core_kb.py index a738a7d..0e008dd 100644 --- a/tests/test_setup_core_kb.py +++ b/tests/test_setup_core_kb.py @@ -151,17 +151,17 @@ def test_default_exclude_patterns_keeps_packaging_metadata(): class TestResolveAssets: def test_default_is_tools_plus_genesis(self): - assert resolve_assets() == list(DEFAULT_ASSETS) == ["tools", "genesis"] + assert resolve_assets() == list(DEFAULT_ASSETS) == ["codes", "genesis"] def test_include_all_is_everything(self): assert resolve_assets(include=["all"]) == all_assets() def test_include_subset_returns_canonical_order(self): # input order shouldn't matter — cheap assets always built first. - assert resolve_assets(include=["aidrin", "tools"]) == ["tools", "aidrin"] + assert resolve_assets(include=["aidrin", "codes"]) == ["codes", "aidrin"] def test_exclude_trims_the_default_set(self): - assert resolve_assets(exclude=["genesis"]) == ["tools"] + assert resolve_assets(exclude=["genesis"]) == ["codes"] def test_exclude_all_is_empty(self): assert resolve_assets(exclude=["all"]) == [] @@ -172,13 +172,13 @@ def test_unknown_asset_raises(self): def test_include_exclude_mutually_exclusive(self): with pytest.raises(ValueError, match="mutually exclusive"): - resolve_assets(include=["tools"], exclude=["genesis"]) + resolve_assets(include=["codes"], exclude=["genesis"]) class TestAssetCollectionName: def test_tools(self): - assert asset_collection_name("tools") == "tools" + assert asset_collection_name("codes") == "codes" def test_catalog_uses_catalog_prefix(self): name = asset_collection_name("genesis") @@ -205,16 +205,16 @@ def test_builds_tools_collection(self, tmp_path): with patch( "dsagt.knowledge.Embedder.create", return_value=self._fake_embedder() ): - result = ensure_assets(["tools"], tmp_path) - assert "tools" in result["built"] + result = ensure_assets(["codes"], tmp_path) + assert "codes" in result["built"] # ChromaIndex.save writes chroma_ids.json — the collection marker. - assert (tmp_path / "tools" / "chroma_ids.json").exists() + assert (tmp_path / "codes" / "chroma_ids.json").exists() def test_is_idempotent(self, tmp_path): with patch( "dsagt.knowledge.Embedder.create", return_value=self._fake_embedder() ): - ensure_assets(["tools"], tmp_path) - second = ensure_assets(["tools"], tmp_path) - assert second["skipped"] == ["tools"] + ensure_assets(["codes"], tmp_path) + second = ensure_assets(["codes"], tmp_path) + assert second["skipped"] == ["codes"] assert second["built"] == [] diff --git a/tests/test_tool_executions.py b/tests/test_tool_executions.py index 22ac09d..0c1707b 100644 --- a/tests/test_tool_executions.py +++ b/tests/test_tool_executions.py @@ -4,9 +4,9 @@ Tests render_execution_text, execution_metadata, index_execution_record, and index_trace_archive. KnowledgeBase embedding is mocked. -Record formats match the actual output from: - - proxy_callback.py (intent + report, execution=None) - - run.py (execution only, no intent/report) +Records match the output of ``dsagt-run`` (run.py): an ``execution`` block +with no intent/report. (The pre-BYOA proxy_callback.py producer, which wrote +intent/report records, was removed — see scratch/excised_proxy_provenance.py.) """ import json @@ -14,11 +14,10 @@ from unittest.mock import MagicMock, patch import numpy as np -import pytest from dsagt.provenance import ( - TOOL_EXECUTIONS_COLLECTION as COLLECTION_NAME, - ToolUseIndexer, + CODE_USE_COLLECTION as COLLECTION_NAME, + CodeUseIndexer, execution_metadata, index_trace_archive, render_execution_text, @@ -41,33 +40,6 @@ def fake_embed(texts: list[str]) -> np.ndarray: return vecs / norms -def make_proxy_record( - tool="fastp", - params=None, - session="s1", - record_id="toolu_001", - agent_output="Filtering complete.", -): - """Proxy callback record: intent + report, execution=None.""" - return { - "record_id": record_id, - "tool_name": tool, - "session_id": session, - "intent": { - "command": tool, - "parameters": params or {"q": 20, "in1": "sample.fq.gz"}, - "timestamp_requested": "2024-01-15T10:30:00+00:00", - "session_id": session, - }, - "execution": None, - "report": { - "agent_output": agent_output, - "timestamp_reported": "2024-01-15T10:30:13+00:00", - "wrapper_used": False, - }, - } - - def make_wrapper_record( tool="fastp", session="s1", @@ -81,7 +53,7 @@ def make_wrapper_record( """Wrapper (dsagt-run) record: execution only, no intent/report.""" return { "record_id": record_id, - "tool_name": tool, + "code_name": tool, "session_id": session, "execution": { "exact_command": [tool, "-q", "20", "--in1", "sample.fq.gz"], @@ -103,14 +75,6 @@ def make_wrapper_record( class TestRenderExecutionText: - def test_proxy_record_uses_intent(self): - """Proxy record renders parameters from intent layer.""" - record = make_proxy_record() - text = render_execution_text(record) - assert "Tool: fastp" in text - assert "Parameters:" in text - assert "Agent report:" in text - def test_wrapper_record_uses_exact_command(self): """Wrapper record renders exact command from execution layer.""" record = make_wrapper_record() @@ -138,12 +102,6 @@ def test_long_stderr_truncated(self): text = render_execution_text(record) assert "..." in text - def test_long_agent_output_truncated(self): - """Long agent output in proxy record is truncated.""" - record = make_proxy_record(agent_output="y" * 600) - text = render_execution_text(record) - assert "..." in text - def test_exact_command_list_joined(self): """exact_command as a list is joined with spaces.""" record = make_wrapper_record() @@ -158,9 +116,9 @@ def test_timing_from_wrapper(self): assert "Duration:" in text def test_minimal_record(self): - """Record with only tool_name renders.""" - text = render_execution_text({"tool_name": "ls"}) - assert "Tool: ls" in text + """Record with only code_name renders.""" + text = render_execution_text({"code_name": "ls"}) + assert "Code: ls" in text def test_empty_record(self): """Record with nothing renders tool as unknown.""" @@ -175,27 +133,14 @@ def test_empty_record(self): class TestExecutionMetadata: - def test_proxy_record_metadata(self): - """Proxy record extracts metadata from top-level and intent fields.""" - record = make_proxy_record() - meta = execution_metadata(record) - - assert meta["tool_name"] == "fastp" - assert meta["session_id"] == "s1" - assert meta["wrapper_used"] == 0 - assert "return_code" not in meta - assert meta["timestamp"] == "2024-01-15T10:30:00+00:00" - assert meta["record_id"] == "toolu_001" - def test_wrapper_record_metadata(self): """Wrapper record extracts metadata from top-level and execution fields.""" record = make_wrapper_record() meta = execution_metadata(record) - assert meta["tool_name"] == "fastp" + assert meta["code_name"] == "fastp" assert meta["session_id"] == "s1" assert meta["return_code"] == 0 - assert meta["wrapper_used"] == 1 assert meta["timestamp"] == "2024-01-15T10:30:01+00:00" assert meta["record_id"] == "rec_001" @@ -207,31 +152,22 @@ def test_failed_execution_metadata(self): def test_missing_session_defaults_to_unknown(self): """Missing session_id defaults to 'unknown'.""" - record = {"tool_name": "ls"} + record = {"code_name": "ls"} meta = execution_metadata(record) assert meta["session_id"] == "unknown" - def test_top_level_fields_preferred(self): - """Top-level tool_name/session_id are used over intent fields.""" - record = make_proxy_record(tool="fastp", session="s1") - record["tool_name"] = "override_tool" - record["session_id"] = "override_session" - meta = execution_metadata(record) - assert meta["tool_name"] == "override_tool" - assert meta["session_id"] == "override_session" - # --------------------------------------------------------------------------- -# ToolUseIndexer — idempotent, incremental heartbeat indexing +# CodeUseIndexer — idempotent, incremental heartbeat indexing # --------------------------------------------------------------------------- -class TestToolUseIndexer: +class TestCodeUseIndexer: def _write(self, trace_dir: Path, record: dict): trace_dir.mkdir(parents=True, exist_ok=True) rid = record["record_id"] - (trace_dir / f"{record.get('tool_name', 'x')}_{rid}.json").write_text( + (trace_dir / f"{record.get('code_name', 'x')}_{rid}.json").write_text( json.dumps(record) ) @@ -246,22 +182,22 @@ def test_incremental_and_idempotent(self, tmp_path): pdir = tmp_path / "proj" (pdir / ".dsagt").mkdir(parents=True) kb = KnowledgeBase(index_dir=pdir / "kb") - indexer = ToolUseIndexer(kb, pdir) + indexer = CodeUseIndexer(kb, pdir) - r1 = make_proxy_record() + r1 = make_wrapper_record() r1["record_id"] = "r1" self._write(pdir / "trace_archive", r1) assert indexer.tick() == 1 # first record indexed assert indexer.tick() == 0 # nothing new → no-op (idempotent) - r2 = make_proxy_record() + r2 = make_wrapper_record() r2["record_id"] = "r2" self._write(pdir / "trace_archive", r2) assert indexer.tick() == 1 # only the new one assert indexer.tick() == 0 # The ack set persists exactly the indexed record ids. - acks = json.loads((pdir / ".dsagt" / "tool_use_acks.json").read_text()) + acks = json.loads((pdir / ".dsagt" / "code_use_acks.json").read_text()) assert set(acks) == {"r1", "r2"} kb.close() @@ -277,7 +213,7 @@ def _write_records(self, trace_dir: Path, records: list[dict]): trace_dir.mkdir(parents=True, exist_ok=True) for i, record in enumerate(records): rid = record.get("record_id", f"rec_{i}") - path = trace_dir / f"{record.get('tool_name', 'unknown')}_{rid}.json" + path = trace_dir / f"{record.get('code_name', 'unknown')}_{rid}.json" path.write_text(json.dumps(record)) def test_indexes_all_records(self, tmp_path): @@ -286,7 +222,7 @@ def test_indexes_all_records(self, tmp_path): self._write_records( trace_dir, [ - make_proxy_record(record_id="t1"), + make_wrapper_record(record_id="t1"), make_wrapper_record(tool="megahit", record_id="t2"), ], ) @@ -310,8 +246,8 @@ def test_skips_already_indexed(self, tmp_path): self._write_records( trace_dir, [ - make_proxy_record(record_id="t1"), - make_proxy_record(record_id="t2"), + make_wrapper_record(record_id="t1"), + make_wrapper_record(record_id="t2"), ], ) @@ -335,7 +271,7 @@ def test_updates_indexed_ids(self, tmp_path): self._write_records( trace_dir, [ - make_proxy_record(record_id="t1"), + make_wrapper_record(record_id="t1"), make_wrapper_record(record_id="t2"), ], ) @@ -382,18 +318,14 @@ def test_nonexistent_directory(self, tmp_path): assert result["indexed"] == 0 kb.close() - def test_skips_records_without_intent_or_execution(self, tmp_path): - """Records missing both intent and execution are skipped.""" + def test_skips_records_without_execution(self, tmp_path): + """Records with no execution layer are skipped (counted as errors).""" trace_dir = tmp_path / "trace_archive" self._write_records( trace_dir, [ - { - "record_id": "bad", - "tool_name": "unknown", - "report": {"agent_output": "something"}, - }, - make_proxy_record(record_id="good"), + {"record_id": "bad", "code_name": "unknown"}, + make_wrapper_record(record_id="good"), ], ) @@ -410,7 +342,7 @@ def test_skips_records_without_intent_or_execution(self, tmp_path): kb.close() def test_accepts_wrapper_only_records(self, tmp_path): - """Wrapper-only records (no intent) are valid and indexed.""" + """Wrapper records (execution only) are valid and indexed.""" trace_dir = tmp_path / "trace_archive" self._write_records( trace_dir, @@ -434,7 +366,7 @@ def test_accepts_wrapper_only_records(self, tmp_path): def test_idempotent_reindex(self, tmp_path): """Running index_trace_archive twice doesn't duplicate entries.""" trace_dir = tmp_path / "trace_archive" - self._write_records(trace_dir, [make_proxy_record(record_id="t1")]) + self._write_records(trace_dir, [make_wrapper_record(record_id="t1")]) with patch("dsagt.knowledge.Embedder.create") as mock_make: mock_embedder = MagicMock() @@ -466,16 +398,16 @@ def _index_records(self, tmp_path, records): trace_dir.mkdir(exist_ok=True) for r in records: rid = r.get("record_id", "x") - path = trace_dir / f"{r.get('tool_name', 'unknown')}_{rid}.json" + path = trace_dir / f"{r.get('code_name', 'unknown')}_{rid}.json" path.write_text(json.dumps(r)) return trace_dir def test_search_by_tool_name(self, tmp_path): - """Index multiple records, search filtered by tool_name.""" + """Index multiple records, search filtered by code_name.""" records = [ - make_proxy_record(tool="fastp", session="s1", record_id="t1"), - make_proxy_record(tool="megahit", session="s1", record_id="t2"), - make_proxy_record(tool="fastp", session="s2", record_id="t3"), + make_wrapper_record(tool="fastp", session="s1", record_id="t1"), + make_wrapper_record(tool="megahit", session="s1", record_id="t2"), + make_wrapper_record(tool="fastp", session="s2", record_id="t3"), ] trace_dir = self._index_records(tmp_path, records) @@ -492,17 +424,17 @@ def test_search_by_tool_name(self, tmp_path): collection=COLLECTION_NAME, top_k=10, rerank=False, - where={"tool_name": "fastp"}, + where={"code_name": "fastp"}, ) for r in results: - assert r["chunk"]["metadata"]["tool_name"] == "fastp" + assert r["chunk"]["metadata"]["code_name"] == "fastp" kb.close() def test_search_by_session(self, tmp_path): """Index multiple records, search filtered by session_id.""" records = [ - make_proxy_record(tool="fastp", session="s1", record_id="t1"), - make_proxy_record(tool="fastp", session="s2", record_id="t2"), + make_wrapper_record(tool="fastp", session="s1", record_id="t1"), + make_wrapper_record(tool="fastp", session="s2", record_id="t2"), ] trace_dir = self._index_records(tmp_path, records) diff --git a/tests/test_trace_scan.py b/tests/test_trace_scan.py index 52e2859..f54eca1 100644 --- a/tests/test_trace_scan.py +++ b/tests/test_trace_scan.py @@ -104,7 +104,7 @@ def test_periodic_collect_emits_complete_turns_and_defers_the_open_last(scan_env ) # roots = [u1, u2, u3]; periodic emits roots[:-1] = u1, u2; u3 deferred. assert collector.collect() == 2 - assert collector._load_acks("mlflow") == {"u1", "u2"} + assert collector._load_acks("mlflow") == {"proj:s:u1", "proj:s:u2"} def test_collect_is_idempotent(scan_env): @@ -136,7 +136,7 @@ def test_deferred_turn_emits_once_a_later_prompt_bounds_it(scan_env): _user("2026-06-19T15:00:04.000Z", "q3", "u3"), ) assert collector.collect() == 1 # u2 now emitted; u3 deferred - assert collector._load_acks("mlflow") == {"u1", "u2"} + assert collector._load_acks("mlflow") == {"proj:s:u1", "proj:s:u2"} def test_final_flush_emits_the_deferred_last_turn(scan_env): @@ -151,7 +151,7 @@ def test_final_flush_emits_the_deferred_last_turn(scan_env): assert collector.collect() == 1 # u1; u2 deferred assert collector.collect(include_last=True) == 1 # end-of-session flush emits u2 assert collector.collect(include_last=True) == 0 # idempotent - assert collector._load_acks("mlflow") == {"u1", "u2"} + assert collector._load_acks("mlflow") == {"proj:s:u1", "proj:s:u2"} def test_collect_on_empty_transcript_is_zero(scan_env): @@ -206,7 +206,8 @@ def test_consumers_ack_independently(tmp_path): # to the good consumer); the failing one logs and holds its mark back. assert collector.collect(include_last=True) == 2 assert good.seen == [{"r1", "r2"}] - assert collector._load_acks("good") == {"r1", "r2"} + # Acks are session-qualified (:). + assert collector._load_acks("good") == {"p:s:r1", "p:s:r2"} assert collector._load_acks("bad") == set() # wedged consumer didn't advance # Good is fully caught up; bad retries (and fails again) — good doesn't redo. @@ -215,6 +216,182 @@ def test_consumers_ack_independently(tmp_path): assert collector._load_acks("bad") == set() +def test_acks_are_session_qualified_no_cross_session_collision(tmp_path): + """Two sessions in one project share the ack file, but each turn is keyed by + ``:`` — so session B's turns (the same per-transcript + ``turn-N`` indices) are not suppressed by session A's acks. Regression for + the cross-session collision that silently dropped every session after the + first. + """ + + def _trace(session_id): + # Both sessions produce the SAME index-based span ids ("r1"/"r2"). + t = Trace("t", session_id, "claude", "p") + t.add_agent_root("r1", "c", start_time=None, prompt="") + t.add_agent_root("r2", "c", start_time=None, prompt="") + return t + + rec_a = _Recorder("mlflow") + collector_a = TraceCollector( + _FakeReader(), + _FakeTranslator(_trace("p:1")), + project="p", + session_id="p:1", + project_dir=tmp_path, + consumers=[rec_a], + ) + assert collector_a.collect(include_last=True) == 2 + + # Session B: new collector, SAME project_dir (shared ack file), new session + # id, identical span ids. Must still emit both turns. + rec_b = _Recorder("mlflow") + collector_b = TraceCollector( + _FakeReader(), + _FakeTranslator(_trace("p:2")), + project="p", + session_id="p:2", + project_dir=tmp_path, + consumers=[rec_b], + ) + assert collector_b.collect(include_last=True) == 2 + assert rec_b.seen == [{"r1", "r2"}] + # Both sessions' qualified keys coexist in the one shared ack file. + assert collector_b._load_acks("mlflow") == { + "p:1:r1", + "p:1:r2", + "p:2:r1", + "p:2:r2", + } + + +def test_make_trace_collector_pins_source(tmp_path): + """A pinned source is read regardless of the newest-file logic, and the + collector reports it via active_source().""" + transcript = tmp_path / "pinned.jsonl" + _append( + transcript, + _user("2026-06-19T15:00:00.000Z", "q1", "u1"), + _asst("2026-06-19T15:00:01.000Z", {"type": "text", "text": "a1"}), + ) + collector = make_trace_collector( + "claude", + tmp_path / "proj", + "p", + "p:1", + f"sqlite:///{tmp_path / 'm.db'}", + source=str(transcript), + ) + assert collector.active_source() == str(transcript) + assert collector.collect(include_last=True) == 1 # the one complete turn + + +def test_record_trace_source_updates_current_session(tmp_path): + from dsagt.session import read_state, record_trace_source, write_state + + proj = tmp_path / "proj" + (proj / ".dsagt").mkdir(parents=True) + write_state(proj, {"sessions": [{"id": 1}], "memory_cursor": {}}) + + record_trace_source(proj, "/path/to/t.jsonl") + assert read_state(proj)["sessions"][-1]["trace_source"] == "/path/to/t.jsonl" + # A non-str token (e.g. a SQLite session id) round-trips through YAML. + record_trace_source(proj, 42) + assert read_state(proj)["sessions"][-1]["trace_source"] == 42 + # No session minted yet → safe no-op. + write_state(tmp_path / "empty", {"sessions": [], "memory_cursor": {}}) + record_trace_source(tmp_path / "empty", "/x") # must not raise + + +def test_catch_up_traces_emits_previous_session_dangling_and_is_idempotent(tmp_path): + """The startup catch-up re-collects the previous session's pinned transcript, + emits the turns the live pass missed, and a second pass is a no-op (the + session-qualified ack files dedupe).""" + from unittest.mock import MagicMock + + from dsagt.session import _catch_up_traces, write_state + + proj = tmp_path / "proj" + (proj / ".dsagt").mkdir(parents=True) + transcript = tmp_path / "prev.jsonl" # pinned, so location is irrelevant + _append( + transcript, + _user("2026-06-19T15:00:00.000Z", "q1", "u1"), + _asst("2026-06-19T15:00:01.000Z", {"type": "text", "text": "a1"}), + _user("2026-06-19T15:00:02.000Z", "q2", "u2"), + _asst("2026-06-19T15:00:03.000Z", {"type": "text", "text": "a2"}), + ) + # Previous session (id=1) recorded its trace source; current session is id=2. + write_state( + proj, + { + "sessions": [{"id": 1, "trace_source": str(transcript)}, {"id": 2}], + "memory_cursor": {}, + }, + ) + config = {"project": "proj", "agent": "claude", "project_dir": str(proj)} + + n = _catch_up_traces(proj, config, MagicMock()) + assert n == 2 # both completed turns flushed via include_last + # Idempotent — acks (keyed proj-1:) suppress a re-collect. + assert _catch_up_traces(proj, config, MagicMock()) == 0 + + +def test_catch_up_traces_noop_without_previous_or_transcript(tmp_path): + from unittest.mock import MagicMock + + from dsagt.session import _catch_up_traces, write_state + + proj = tmp_path / "proj" + (proj / ".dsagt").mkdir(parents=True) + config = {"project": "proj", "agent": "claude", "project_dir": str(proj)} + + # Only one session → nothing to catch up. + write_state(proj, {"sessions": [{"id": 1}], "memory_cursor": {}}) + assert _catch_up_traces(proj, config, MagicMock()) == 0 + + # Previous session exists but recorded no transcript (SQLite agent / too + # short) → skip rather than risk reading the new session's transcript. + write_state( + proj, {"sessions": [{"id": 1}, {"id": 2}], "memory_cursor": {}} + ) + assert _catch_up_traces(proj, config, MagicMock()) == 0 + + +def test_sqlite_reader_pins_to_a_specific_session(tmp_path): + """SQLite agents pin uniformly: a pinned GooseReader reads the *specified* + session, not the latest — so the catch-up backstops goose/opencode/cline + too, not just the JSONL agents. (opencode/cline mirror this shape.) + """ + import os + import sqlite3 + + from dsagt.traces import GooseReader + + db = tmp_path / "sessions.db" + con = sqlite3.connect(db) + con.executescript( + "CREATE TABLE sessions (id TEXT, working_dir TEXT, updated_at INTEGER);" + "CREATE TABLE messages (id INTEGER PRIMARY KEY, session_id TEXT, role TEXT," + " content_json TEXT, created_timestamp INTEGER);" + ) + wd = os.path.abspath(tmp_path / "proj") + con.execute("INSERT INTO sessions VALUES ('A', ?, 100)", (wd,)) # older + con.execute("INSERT INTO sessions VALUES ('B', ?, 200)", (wd,)) # newer + con.execute("INSERT INTO messages VALUES (1, 'A', 'user', '[\"qA\"]', 1)") + con.execute("INSERT INTO messages VALUES (2, 'B', 'user', '[\"qB\"]', 2)") + con.commit() + con.close() + + reader = GooseReader(tmp_path / "proj", db_path=db) + # Unpinned → newest session (B), and reports its id. + assert reader.active_source() == "B" + assert reader.read()[0]["content"] == ["qB"] + # Pinned to the older session A → reads exactly A (the catch-up's job). + reader.pin("A") + assert reader.active_source() == "A" + assert reader.read()[0]["content"] == ["qA"] + + def test_emitted_traces_land_in_the_store(scan_env): import mlflow diff --git a/tests/test_traces.py b/tests/test_traces.py index 2a43155..2b70cdf 100644 --- a/tests/test_traces.py +++ b/tests/test_traces.py @@ -1,15 +1,14 @@ """Fixture tests for the canonical trace waist + Claude translator (Phase 2). Pure, no disk / no network. Two things under test: -- ``to_exchanges`` projects LLM spans onto the conversational shape the existing - Phase-3 extractor (``memory.build_extraction_prompt``) consumes — the seam. +- ``to_exchanges`` projects LLM spans onto the conversational shape the episodic + indexer chunks + embeds (carrying ``turn_id`` + content) — the seam. - ``ClaudeTranslator`` turns raw transcript records into a ``Trace`` with the autolog-style AGENT/LLM/TOOL span layout. """ import json -from dsagt.memory import build_extraction_prompt from dsagt.traces import ( ClaudeReader, ClaudeTranslator, @@ -86,14 +85,16 @@ def test_to_exchanges_uses_request_window_directly(): assert ex2["response"] == [{"type": "text", "text": "The file has 1000 rows."}] -def test_projection_feeds_the_real_extraction_prompt(): - """The waist's whole justification: exchanges render through the existing - Phase-3 prompt builder with no adapter, carrying the real content.""" - prompt = build_extraction_prompt(_windowed_trace().to_exchanges()) - assert "Profile sales.csv" in prompt - assert "profile" in prompt # tool_use name (in the assistant response) - assert "1000 rows" in prompt # tool_result, via _extract_block_text - assert "The file has 1000 rows." in prompt # final answer +def test_projection_carries_turn_id_and_content(): + """The waist's whole justification: to_exchanges carries ``turn_id`` (groups + a turn's chunks) and the real content the episodic indexer embeds.""" + exchanges = _windowed_trace().to_exchanges() + assert all(ex["turn_id"] for ex in exchanges) # span id, for chunk grouping + flat = json.dumps(exchanges) + assert "Profile sales.csv" in flat + assert "profile" in flat # tool_use name (in the assistant response) + assert "1000 rows" in flat # tool_result content + assert "The file has 1000 rows." in flat # final answer def test_none_tolerant_usage_and_timing(): @@ -103,7 +104,7 @@ def test_none_tolerant_usage_and_timing(): ) assert span["usage"] is None and span["start_time"] is None assert trace.to_exchanges() == [ - {"timestamp": None, "new_messages": [], "response": []} + {"turn_id": "s1", "timestamp": None, "new_messages": [], "response": []} ] diff --git a/tests/test_traces_codex.py b/tests/test_traces_codex.py index e1da15e..1dd3985 100644 --- a/tests/test_traces_codex.py +++ b/tests/test_traces_codex.py @@ -209,4 +209,6 @@ def test_end_to_end_through_the_sink(mlflow_sqlite): assert len(traces) == 2 # the tool-bearing turn rendered llm + tool spans under its agent root shapes = {len(t.data.spans) for t in traces} - assert 5 in shapes # root + 2 llm + 2 tool + # tool-bearing turn: root + 2 llm + 2 tool = 5; second turn: root + 1 llm = 2. + # Pin both — `5 in shapes` alone would pass even if turn 2 collapsed. + assert shapes == {5, 2} From ad35b08e760ccbac28f0f99cf17f56d13d30378d Mon Sep 17 00:00:00 2001 From: aarontuor Date: Thu, 2 Jul 2026 11:27:14 -0700 Subject: [PATCH 19/44] docs: split into per-capability pages; lead with interactive `dsagt init` - Restructure the docs into one page per capability, each ending with a hands-on "Try it" walkthrough and a pointer to the use cases: provenance (code registry + dsagt-run + reconstruction), knowledge-base (domain-knowledge cataloging + hybrid vector search + shared-store note), skills, memory, observability. Split the old codes-&-skills page, move memory + execution-record content out of knowledge-base, and regroup the mkdocs nav under a "Capabilities" section. - Docs now lead with the interactive `dsagt init` menu; every walkthrough uses bare `dsagt init` + `dsagt start`. The pre-menu flags (positional name, --agent, --location, --include/--exclude, --episodic) are documented as deprecated backcompat only in the CLI reference and README. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 33 +++++++-------- docs/architecture.md | 2 +- docs/cli.md | 16 ++++++- docs/knowledge-base.md | 48 +++++++++++++-------- docs/memory.md | 39 +++++++++++++++++ docs/observability.md | 16 ++++++- docs/provenance.md | 63 ++++++++++++++++++++++++++++ docs/quickstart.md | 23 ++++------ docs/{tools-skills.md => skills.md} | 65 +++++++++++------------------ mkdocs.yml | 9 ++-- 10 files changed, 215 insertions(+), 99 deletions(-) create mode 100644 docs/memory.md create mode 100644 docs/provenance.md rename docs/{tools-skills.md => skills.md} (51%) diff --git a/README.md b/README.md index 5f4fd9e..ea16e0a 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Then start your agent from the project directory, use dsagt cli, or open a VS co cd ~/dsagt-projects/my-project && claude # …or: dsagt start my-project ``` -To upgrade later, reinstall — re-running `dsagt init my-project` reconfigures an existing project in place: +To upgrade later, reinstall — re-running `dsagt init` reconfigures an existing project in place: ```bash pip install --upgrade "git+https://github.com/AI-ModCon/dsagt.git" @@ -70,14 +70,13 @@ source .venv/bin/activate # so `dsagt` is on PATH # A convenience variable for the demo paths below (not a normal dsagt step) export SMOKE_DIR="$(pwd)/tests/smoke_test" -# 1. Create a project called quickstart. Interactive `dsagt init` prompts for -# the agent, location, knowledge collections, and skill sources, and -# sets up the knowledge base on first run (a ~130 MB local -# embedder downloads once). `--agent` makes it non-interactive: -dsagt init quickstart --agent claude +# 1. Create a project. `dsagt init` is interactive — follow the menu to name it +# `quickstart`, pick your agent, and choose knowledge collections + skill sources. +# It sets up the knowledge base on first run (a ~130 MB local embedder downloads once). +dsagt init -# 2. Launch the agent from the project directory: -cd ~/dsagt-projects/quickstart && claude # …or: dsagt start quickstart +# 2. Launch the agent in the project: +dsagt start quickstart # …or: cd ~/dsagt-projects/quickstart && ``` Inside the agent, paste these prompts one at a time (substitute the absolute path you exported as `$SMOKE_DIR` — the chat doesn't expand env vars): @@ -133,14 +132,9 @@ End-to-end walkthroughs for representative scientific domains live in [`use_case ## Project Directory -Default location: `~/dsagt-projects//`. Override with `--location`: +`dsagt init` prompts for the project location, defaulting to `~/dsagt-projects//` (e.g. enter `/data/runs` to place it at `/data/runs/my-project/`, or `.` for the current directory). -```bash -dsagt init my-project --agent claude --location /data/runs # /data/runs/my-project/ -dsagt init my-project --agent claude --location . # ./my-project/ -``` - -Projects are registered in `~/dsagt-projects/projects.yaml` so `dsagt info ` works from any directory. The project's data — knowledge base, trace store, registered codes, skills, audit records — is agent-agnostic, so re-running `dsagt init ` and choosing a different agent switches platforms while preserving everything you've accumulated (it prompts before any destructive change). +Projects are registered in `~/dsagt-projects/projects.yaml` so `dsagt info ` works from any directory. The project's data — knowledge base, trace store, registered codes, skills, audit records — is agent-agnostic, so re-running `dsagt init` for the same project and choosing a different agent switches platforms while preserving everything you've accumulated (it prompts before any destructive change). ``` ~/dsagt-projects/cheese-metagenome/ @@ -196,7 +190,7 @@ The agent searches these collections semantically: | **Knowledge Collections** | NeMo Curator + AIDRIN reference collections; user-ingested docs | `dsagt init` (chosen collections) + agent's `kb_ingest` | | **Explicit Memory** | User-confirmed facts | Agent's `kb_remember` (also written to `/.dsagt/explicit_memories.yaml`); the agent fetches via `kb_get_memories` on demand, not auto-loaded at session start | | **Code Execution Records** | `dsagt-run` execution traces | `dsagt-run` writes JSON to `/trace_archive/`; indexed for search during the session, and before `reconstruct_pipeline` | -| **Episodic Memory** | Captured session turns | **Opt-in** (`dsagt init --episodic`): DSAgt captures each completed turn into `session_memory` during the session (mechanical chunk + embed). Retrieval is recency-weighted. | +| **Episodic Memory** | Captured session turns | **Opt-in** (enabled in the `dsagt init` menu): DSAgt captures each completed turn into `session_memory` during the session (mechanical chunk + embed). Retrieval is recency-weighted. | The embedding backend is local (sentence-transformers, CPU-side, no API key). @@ -207,7 +201,7 @@ The agent searches via `kb_search` and writes via `kb_ingest` / `kb_remember`. R DSAgt has two memory types, both retrievable via `kb_search` / `kb_get_memories`: - **Explicit memory** — user-confirmed facts the agent writes with `kb_remember` (mirrored to `/.dsagt/explicit_memories.yaml`). Always on; degrades to pure-YAML if the vector store is unavailable. -- **Episodic memory** — automatic session capture, **opt-in** (`dsagt init --episodic`, off by default). As the session runs, DSAgt reads the agent's transcript and captures each completed turn into the `session_memory` collection — a fast, local chunk-and-embed pass. +- **Episodic memory** — automatic session capture, **opt-in** (enabled in the `dsagt init` menu, off by default). As the session runs, DSAgt reads the agent's transcript and captures each completed turn into the `session_memory` collection — a fast, local chunk-and-embed pass. Retrieval is **recency-weighted** (`episodic.recency_half_life_days`): a newer turn edges out a stale one, but as a bounded boost — a strongly-relevant old turn is never buried. @@ -226,8 +220,7 @@ Each launch gets a session id that every span carries, so you can filter the tra | Command | Description | |---------|-------------| -| `dsagt init []` | Create or reconfigure a project — interactive: agent, location, knowledge collections, skill sources, and the episodic-memory opt-in; sets up the KB and writes the per-agent MCP config | -| `dsagt init --agent [--location ] [--include … \| --exclude …] [--episodic]` | Same, non-interactively (for scripts/CI); `--episodic` enables episodic memory (mechanical chunk + embed capture) | +| `dsagt init` | Create or reconfigure a project — interactive menu for name, location, agent, knowledge collections, skill sources, and the episodic-memory opt-in; sets up the KB and writes the per-agent MCP config | | `dsagt start ` | Launch the agent in the project directory (equivalent to `cd && `) | | `dsagt info [--json]` | Resolved config (with source per value) and a session/trace summary | | `dsagt list` | List all projects with agent and path | @@ -235,6 +228,8 @@ Each launch gets a session id that every span carries, so you can filter the tra | `dsagt rm [-y] [--keep-files]` | Unregister a project (and optionally delete its directory) | | `dsagt smoke-test [--agent claude\|goose\|codex\|opencode\|cline]` | End-to-end install verification | +> **Deprecated `dsagt init` flags (backcompat).** The pre-menu flags still work for scripts/CI but are deprecated in favor of the interactive menu: `` (positional), `--agent `, `--location `, `--include … | --exclude …`, and `--episodic`. Passing any of them skips the corresponding prompt; new usage should prefer bare `dsagt init`. + Skill catalogs are managed from the agent via the MCP tools (`add_skill_source` / `search_skills` / `install_skill`), and traces are viewed with `mlflow ui --backend-store-uri sqlite:////mlflow.db`. For tests, troubleshooting, and other developer-facing material, see [developer.md](developer.md). diff --git a/docs/architecture.md b/docs/architecture.md index dcad5e6..65b72f7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -47,4 +47,4 @@ DSAgt adds two memory extensions that complement the host agent's own memory (wh # cline: .clinerules/, cline_mcp_settings.json ``` -Projects are registered in `~/dsagt-projects/projects.yaml` so `dsagt info ` works from any directory. The project's data is agent-agnostic — re-running `dsagt init --agent ` switches agent platforms while preserving all accumulated knowledge and traces. +Projects are registered in `~/dsagt-projects/projects.yaml` so `dsagt info ` works from any directory. The project's data is agent-agnostic — re-running `dsagt init` for the same project and choosing a different agent switches platforms while preserving all accumulated knowledge and traces. diff --git a/docs/cli.md b/docs/cli.md index 9b6f3f6..15da548 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -6,7 +6,7 @@ All commands are available after [installation](index.md#installation) and activ | Command | Description | |---------|-------------| -| `dsagt init []` | Create or reconfigure a project — interactive: agent platform, location, knowledge collections, skill sources, and the episodic-memory opt-in. Sets up the knowledge base and writes the per-agent instructions + MCP config. Re-runnable as a settings editor. | +| `dsagt init` | Create or reconfigure a project — interactive menu for name, location, agent platform, knowledge collections, skill sources, and the episodic-memory opt-in. Sets up the knowledge base and writes the per-agent instructions + MCP config. Re-runnable as a settings editor. | | `dsagt start ` | Launch the agent in the project directory (equivalent to `cd && `); on exit, runs post-session catch-up. | | `dsagt list` | List all projects with agent and path. | | `dsagt info [--json]` | Resolved config (with the source of each value) and a session/trace summary read from the SQLite store. | @@ -14,6 +14,20 @@ All commands are available after [installation](index.md#installation) and activ | `dsagt rm [-y] [--keep-files]` | Unregister a project and optionally delete its directory. | | `dsagt smoke-test [--agent claude\|goose\|codex\|opencode\|cline]` | End-to-end install verification. | +### Deprecated `dsagt init` flags (backcompat) + +The pre-menu flags still work — the automation/CI path — but are **deprecated** in favor of the interactive menu. Each one skips its corresponding prompt: + +| Flag | Prompt it replaces | +|------|--------------------| +| `` (positional) | Project name | +| `--agent ` | Agent platform | +| `--location ` | Project location | +| `--include … \| --exclude …` | Knowledge collections / skill sources | +| `--episodic` | "Enable episodic memory?" | + +New usage should prefer bare `dsagt init` and the menu. + ## Project Location The default project location is `~/dsagt-projects//`. diff --git a/docs/knowledge-base.md b/docs/knowledge-base.md index d02cccc..99ad6bd 100644 --- a/docs/knowledge-base.md +++ b/docs/knowledge-base.md @@ -1,34 +1,46 @@ # Knowledge Base -DSAgt maintains independently-partitioned ChromaDB collections, with six core DSAgt collections set up and used through `dsagt init` and the agent's MCP tools: +The knowledge base is DSAgt's catalog of **domain knowledge** — reference corpora and your own documents — that the agent searches to ground its work on scientific data-processing and AI-readiness evaluation. Rather than let the agent guess at a library's API or a standard's requirements, it retrieves the relevant passages first, then acts. -## Collections +## Domain-knowledge collections | Collection | Source | Populated by | |---|---|---| -| **Code Specs** | Bundled CLI code specs | `dsagt init` (always set up) | -| **Skills Catalog** | Installable skills from external repos (one per source) | `dsagt init` (chosen sources) + `add_skill_source` | -| **Domain Knowledge** | NeMo Curator + AIDRIN reference collections; user-ingested docs | `dsagt init` (chosen collections) + agent's `kb_ingest` | -| **Explicit Memory** | User-confirmed facts | Agent's `kb_remember` (also written to `/.dsagt/explicit_memories.yaml`) | -| **Episodic Memory** (`session_memory`) | Chunked session turns | Captured during the session (opt-in; see below) | -| **Code Execution Records** (`code_use`) | `dsagt-run` execution traces | `dsagt-run` writes JSON to `/trace_archive/`; indexed for search during the session | +| **Reference corpora** | NeMo Curator + AIDRIN (data-curation and AI-data-readiness references) | `dsagt init` (chosen collections) | +| **Your documents** | Papers, standards, protocols, schemas you ingest | Agent's `kb_ingest` | -## Explicit Memory +The agent works these with three tools: `kb_ingest` (index a file or directory into a named collection — long ingests run in the background), `kb_search` (retrieve across one or more collections), and `kb_list_collections` (see what's indexed). -Explicit memories are facts the user confirms during a session. The agent saves them via `kb_remember`, which writes to both the ChromaDB collection and `/.dsagt/explicit_memories.yaml`. The agent fetches them via `kb_get_memories` on demand (typically when you ask it to recall something) — they are not auto-loaded at session start. If the vector store is unavailable, explicit memory degrades to pure-YAML. +## Why hybrid vector search -## Episodic Memory +Retrieval is **hybrid** — dense semantic embeddings fused with sparse BM25 keyword matching — and it's on by default per collection: -When enabled, DSAgt reads the agent's transcript as the session runs and captures each completed turn into the `session_memory` collection — a fast, local chunk-and-embed pass that reuses the same embedder as the rest of the knowledge base. +- **Semantic embeddings** catch paraphrase and synonymy: a query about "missing values" finds a passage on "null rates" even with no shared words. +- **BM25 keyword matching** catches the exact terms embeddings tend to under-rank — identifiers, gene names, parameter flags, standard names — where a literal match matters. +- **Per-collection partitioning** scopes a search to a domain, so a materials-science query isn't diluted by genomics references. +- **Optional cross-encoder reranking** re-scores the top candidates for precision when it's worth the extra pass. -Retrieval over `session_memory` is filtered to session, and then regex over key query terms prior to a **recency-weighted** ranked semantic-vector retrieval: a newer turn edges out a stale one as a bounded boost, so a corrected fact wins by recency while a strongly-relevant old turn is never buried. +The default embedder is a local sentence-transformers model (~130 MB, CPU-side, no API key). +## One store, many concerns -## Search +The same vector store backs more than domain knowledge. DSAgt's [memory](memory.md) (explicit + episodic), [skills discovery](skills.md) (the installable-skill catalog), and [code execution tracking](provenance.md) (the `code_use` records) each live in it as their own partitioned collections, sharing one embedder and one ChromaDB. This page covers domain-knowledge cataloging; those pages carry the depth on their respective collections. -The agent searches all collections via `kb_search` and writes via `kb_ingest` / `kb_remember`. Registered codes have their own `search_registry` route over the same backend. Skills are discovered separately — installed ones natively by the agent, installable ones via `search_skills` over the external catalog (see [Codes & Skills](codes-skills.md)). +## Setup -Hybrid search (semantic embeddings + keyword BM25) is on by default per collection. Optional reranking sharpens the top results. The default embedder is a local sentence-transformers model (~130 MB, CPU-side, no API key). +`dsagt init` sets up the knowledge base from your choices in the interactive menu. Re-running `dsagt init` on an existing project reconfigures it in place. -## Setup +## Try it + +```bash +dsagt init # name it `demo`, and enable the AIDRIN collection in the prompts +dsagt start demo +``` + +Then, in the agent: + +1. > Ingest the docs in `knowledge/` into a collection named `domain`. +2. > Search the `domain` and `aidrin` collections for how to assess data completeness. + +## In practice -`dsagt init` sets up the KB. `--include` / `--exclude` (asset names, or `all`) select which collections to include; the bundled Code Specs collection is always included. Re-running `dsagt init` on an existing project reconfigures it in place. +See the [Use Cases](use-cases/index.md), where domain references and ingested protocols guide the agent through curating a real scientific dataset. diff --git a/docs/memory.md b/docs/memory.md new file mode 100644 index 0000000..de68ddf --- /dev/null +++ b/docs/memory.md @@ -0,0 +1,39 @@ +# Memory + +DSAgt gives the agent two kinds of persistent memory backed by the project's vector store: **explicit memory** — facts the user confirms — and opt-in **episodic memory** — an automatic record of session turns. Both are retrievable by the agent with `kb_search` / `kb_get_memories` MCP tools. + +## Explicit memory + +Explicit memories are facts the user confirms during a session. The agent saves them via `kb_remember`, which writes to both the ChromaDB collection and `/.dsagt/explicit_memories.yaml`. It fetches them via `kb_get_memories` on demand — typically when you ask it to recall something — so they are not auto-loaded at session start. + +Explicit memory is always on. It is the reliable path: when you say "remember that…", the agent records the fact so a future session can retrieve it. + +## Episodic memory + +Episodic memory is **opt-in**. When enabled, DSAgt reads the agent's transcript as the session runs and captures each completed turn into the `session_memory` collection — a fast, local chunk-and-embed pass that reuses the same embedder as the rest of the knowledge base. + +Retrieval over `session_memory` filters first to a session, then by regex over the query's key terms, before a final **recency-weighted** semantic ranking: a newer turn edges out a stale one as a bounded boost, so a corrected fact wins by recency while a strongly-relevant old turn is never buried. + +## Try it + +Explicit memory needs no setup: + +```bash +dsagt init # name it `demo`; answer "yes" to "Enable episodic memory?" to also capture turns +dsagt start demo +``` + +Then, in the agent: + +1. > Remember that `samples.csv` has null values in the status and timestamp columns. +2. > *(later, or in a new session)* What do you remember about the samples dataset? + +Confirm it persisted to disk: + +```bash +cat ~/dsagt-projects/demo/.dsagt/explicit_memories.yaml +``` + +## In practice + +See the [Use Cases](use-cases/index.md), where confirmed facts about a dataset — its quirks, thresholds, and decisions — carry forward across a multi-step curation session. diff --git a/docs/observability.md b/docs/observability.md index 024898e..613a74b 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -34,6 +34,18 @@ Every span carries the project's session id (minted per launch into `/. The trace scan runs as a periodic heartbeat inside the long-lived MCP server — the one DSAgt process alive in every launch flow. Each tick reads new transcript records, translates completed turns, and fans out to subscribers (the MLflow sink always; the episodic-memory extractor when enabled). Correctness rests on idempotency: each subscriber keeps its own ack set, so a re-tick or a next-session catch-up can never double-log or lose a turn. The same heartbeat incrementally indexes `dsagt-run` code-execution records into the `code_use` collection. -## Provenance and Reconstruction +The same heartbeat also indexes `dsagt-run` execution records for search — the on-disk records and pipeline reconstruction are covered under [Provenance](provenance.md). -Code execution records on disk (`trace_archive/.json`) provide the canonical provenance chain. The agent calls `reconstruct_pipeline` to render the archive as a reproducible bash script or Snakemake workflow (which also flushes the latest code-use records into the searchable index first). +## Try it + +```bash +dsagt init # follow the prompts: name it `demo`, then pick your agent +dsagt start demo # …run a prompt or two, then exit the agent +mlflow ui --backend-store-uri sqlite:///$HOME/dsagt-projects/demo/mlflow.db +``` + +Open the MLflow UI to see both feeds in one store: DSAgt's own `kb.*` / `code.execute` spans and the per-turn agent traces recovered from the transcript. `dsagt info demo` prints the same session/trace summary from the command line. + +## In practice + +See the [Use Cases](use-cases/index.md) to watch a full curation session — every retrieval, code run, and agent turn — land in the trace store as it happens. diff --git a/docs/provenance.md b/docs/provenance.md new file mode 100644 index 0000000..6f56d7f --- /dev/null +++ b/docs/provenance.md @@ -0,0 +1,63 @@ +# Provenance + +DSAgt makes every data operation a reproducible, auditable step. The agent registers a **code** — a CLI executable — and every run of that code is wrapped for provenance capture, so the whole pipeline can later be reconstructed from the record. + +## Codes + +Codes are CLI executables defined as markdown files with YAML frontmatter under `/codes/`. The agent registers new codes via the MCP server's `save_code_spec` tool and finds existing ones via `search_registry`. + +A code spec includes: + +- A YAML frontmatter block describing the command, arguments, dependencies, and tags. +- A markdown body with usage examples and notes for the agent. + +Example code spec: + +```markdown +--- +name: csvstat +command: csvstat +dependencies: [] +tags: [csv, statistics] +--- + +Prints descriptive statistics for all columns in a CSV file. + +Usage: csvstat [options] [FILE] +``` + +DSAgt wraps every registered code with `dsagt-run` for provenance capture and `uv run --with` for Python dependencies, so the agent can call any code without managing environments manually. It ships one bundled code, `scan_directory`, indexed for search by `dsagt init`. + +## Execution capture + +Every registered code runs through the `dsagt-run` wrapper. For each call it records the command, arguments, exit code, duration, input/output file counts, and truncated stderr to `/trace_archive/.json`, and emits a `code.execute` span to the trace store. The MCP server incrementally indexes those records into the `code_use` collection, so past executions are searchable. + +The wrapper is the whole point of code-mediated data access: a direct shell or editor call leaves no record and breaks reconstruction. + +## Pipeline reconstruction + +The on-disk execution records are the canonical provenance chain. The agent calls `reconstruct_pipeline` to render the trace archive as a reproducible **bash script** (`format="bash"`) or **Snakemake workflow** (`format="snakemake"`). It flushes the latest records into the searchable index first, then walks the dependency graph inferred from each step's input/output files to order the steps. + +## Try it + +```bash +dsagt init # follow the prompts: name it `demo`, then pick your agent +dsagt start demo # launch the agent in the project +``` + +Then, in the agent: + +1. > Register the csvkit codes `csvstat` and `csvcut`. +2. > Use `csvstat` from the registry on `data/samples.csv` and summarize the columns. +3. > Reconstruct the pipeline as a bash script. + +Afterwards, inspect the trail: + +```bash +ls ~/dsagt-projects/demo/{codes,trace_archive} # the specs + execution records +mlflow ui --backend-store-uri sqlite:///$HOME/dsagt-projects/demo/mlflow.db # code.execute spans +``` + +## In practice + +See the [Use Cases](use-cases/index.md) for provenance-captured pipelines on real datasets — for example registering `fastp` and `megahit` as codes and reconstructing a genomics QC-and-assembly pipeline end to end. diff --git a/docs/quickstart.md b/docs/quickstart.md index 0f6571e..ed034fd 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -13,14 +13,13 @@ pip install "git+https://github.com/AI-ModCon/dsagt.git" # Set a convenience variable for the smoke test directory (not a normal dsagt step) export SMOKE_DIR="$(pwd)/tests/smoke_test" -# 1. Create a project called quickstart. Interactive `dsagt init` prompts for the -# agent, location, knowledge collections, skill sources, and episodic memory, -# and sets up the knowledge base on first run. --agent makes it -# non-interactive (a ~130 MB local embedder downloads once): -dsagt init quickstart --agent claude - -# 2. Launch the agent from the project directory: -cd ~/dsagt-projects/quickstart && claude # …or: dsagt start quickstart +# 1. Create a project. `dsagt init` is interactive — follow the menu to name it +# `quickstart`, pick your agent, and choose knowledge collections + skill sources. +# It sets up the knowledge base on first run (a ~130 MB local embedder downloads once). +dsagt init + +# 2. Launch the agent in the project: +dsagt start quickstart # …or: cd ~/dsagt-projects/quickstart && ``` ## Agent Prompts @@ -77,10 +76,4 @@ dsagt smoke-test --agent claude ## Optional: Episodic Memory -Pass `--episodic` at init (or choose it in the interactive prompt) to have the MCP server capture each session turn into a searchable `session_memory` collection: - -```bash -dsagt init quickstart --agent claude --episodic -``` - -Capture is mechanical (chunk + embed) and reuses the local embedder, so there's nothing extra to download. See [Knowledge Base → Episodic Memory](knowledge-base.md#episodic-memory). +Answer **yes** to "Enable episodic memory?" in the `dsagt init` prompts to have the MCP server capture each session turn into a searchable `session_memory` collection. Capture is mechanical (chunk + embed) and reuses the local embedder, so there's nothing extra to download. See [Memory → Episodic Memory](memory.md#episodic-memory). diff --git a/docs/tools-skills.md b/docs/skills.md similarity index 51% rename from docs/tools-skills.md rename to docs/skills.md index e45db13..f0bff24 100644 --- a/docs/tools-skills.md +++ b/docs/skills.md @@ -1,40 +1,10 @@ -# Codes and Skills +# Skills -## Codes +Skills are instruction-based agent workflows — a directory with a `SKILL.md` and optional reference docs that the agent reads and follows. DSAgt lets the agent discover and install skills from external catalogs on demand, without loading thousands of them into context. -Codes are CLI executables defined as markdown files with YAML frontmatter under `/codes/`. The agent registers new codes via the MCP server's `save_code_spec` tool. +Skills live in `/skills/`. Each is a directory containing a `SKILL.md` file and optional reference documents. -A code spec includes: - -- A YAML frontmatter block describing the command, arguments, dependencies, and tags. -- A markdown body with usage examples and notes for the agent. - -Example code spec structure: - -```markdown ---- -name: csvstat -command: csvstat -dependencies: [] -tags: [csv, statistics] ---- - -Prints descriptive statistics for all columns in a CSV file. - -Usage: csvstat [options] [FILE] -``` - -DSAgt wraps every registered code with `dsagt-run` for provenance capture and `uv run --with` for Python dependencies, so the agent can call any code without managing environments manually. - -### Bundled Codes - -DSAgt ships a `scan_directory` code that is indexed into the Code Specs collection by `dsagt init` (always set up). - -## Skills - -Skills are instruction-based agent workflows in `/skills/`. Each skill is a directory containing a `SKILL.md` file and optional reference documents. - -### Skill discovery architecture +## Two tiers ![DSAgt skill routing](assets/skills-routing.png) @@ -43,19 +13,34 @@ Skills live in **two tiers**, and a single MCP service — the **SkillRouter** - **Catalog tier** — skills that exist in external repositories but are *not yet installed*. DSAgt federates many sources (`k-dense-ai`, `anthropic`, `antigravity`, `composio`, `genesis`, or any git URL); each is cloned and indexed into its own collection. The agent browses this tier with `search_skills` and manages sources with `add_skill_source` / `list_skill_sources`. - **Installed + created tier** — skills drawn into the project's Skill Directory (`/skills/`), either installed from the catalog (`install_skill`) or authored in place (e.g. with the bundled `skill-creator`). At `dsagt start` these are mirrored into each agent's *native* skill directory (`.claude/`, `.agents/`, `.cline/`), where the agent auto-discovers and auto-invokes them. -The diagram's three bands trace a skill's lifecycle: **Discovery** (the router) → **Registration** (the searchable catalog) → **Progressive Exposure** (the native Skill Directory the agent loads on its own). +The lifecycle runs **Discovery** (the router) → **Registration** (the searchable catalog) → **Progressive Exposure** (the native Skill Directory the agent loads on its own). -#### Design motivation +## Design motivation - **Search is catalog-only.** Every supported agent (Claude, Codex, Goose, Cline, opencode) natively auto-discovers `SKILL.md` folders, so installed skills never need to be indexed or returned by a tool — the harness already loads them. So `search_skills` handles what native discovery can't reach: a catalog of potentially thousands of *un*installed skills, searchable without holding them all in context. Catalogs are indexed on name, description, and tags, which keeps those summaries compact and avoids diluting the embedding with full SKILL.md bodies. - **Keyword fallback, no embedder required.** When no embedding model is configured, `search_skills` falls back to a keyword match over the local clones, so it still works (just less fuzzy) — no model or API key needed. - **One router, not scattered policy.** Backend selection (semantic vs. keyword), the catalog/installed split, and source bookkeeping all live in the SkillRouter rather than being re-implemented at each MCP and CLI call site, so the behavior can't drift between them. - **Federated and provenance-preserving.** Each source is an independent per-source collection, so re-syncing one never disturbs another; installing a catalog skill preserves its upstream `LICENSE`/`NOTICE` and stamps a `PROVENANCE.txt` into the installed directory. -### Bundled Skills +## Bundled and authored skills + +DSAgt ships a `skill-creator` skill (for scaffolding new `SKILL.md` skills). Bundled and installed skills are **not** indexed for search — the agent auto-discovers `SKILL.md` folders natively — so `search_skills` is reserved for the catalog tier. Domain skills, including the MODCON `datacard-generator`, are sourced from external catalogs rather than bundled, so they stay current upstream. + +To add one by hand, place a new directory under `/skills/` with a `SKILL.md` describing the workflow; the next `dsagt start` mirrors it into the agent's native skill directory, after which the agent auto-discovers and invokes it — no indexing step. + +## Try it + +```bash +dsagt init # follow the prompts: name it `demo`, then pick your agent +dsagt start demo # launch the agent in the project +``` + +Then, in the agent: -DSAgt ships a `skill-creator` skill in `src/dsagt/skills/` (for scaffolding new SKILL.md skills). Bundled and installed skills are **not** indexed for search — every supported agent natively auto-discovers `SKILL.md` folders, so `search_skills` is reserved for the *catalog* tier (skills you can install but haven't yet). Domain skills — including the MODCON `datacard-generator` — are sourced from external catalogs (enabled from the agent with `add_skill_source`, or chosen at `dsagt init`) rather than bundled, so they stay current upstream. +1. > List the skill sources and their sync status. +2. > Sync the `genesis` catalog and search it for a data-card skill. +3. > Install the one that fits, then use it on this project. -### Adding Skills +## In practice -Place a new directory under `/skills/` with a `SKILL.md` describing the workflow. The next `dsagt start` mirrors it into the agent's native skill directory (e.g. `.claude/skills/`), after which the agent auto-discovers and invokes it — no indexing step. +See the [Use Cases](use-cases/index.md), which draw on installed skills — such as the MODCON data-card generator — while working a real dataset end to end. diff --git a/mkdocs.yml b/mkdocs.yml index dda4d9d..aa8c45a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -52,10 +52,13 @@ nav: - Home: index.md - Quick Start: quickstart.md - Architecture: architecture.md + - Capabilities: + - Provenance: provenance.md + - Knowledge Base: knowledge-base.md + - Skills: skills.md + - Memory: memory.md + - Observability: observability.md - MCP Servers: mcp-servers.md - - Knowledge Base: knowledge-base.md - - Tools & Skills: tools-skills.md - - Observability: observability.md - CLI Reference: cli.md - Use Cases: - Overview: use-cases/index.md From 68935e71600b3466fe96251a57ba2b32f17cc120 Mon Sep 17 00:00:00 2001 From: aarontuor Date: Thu, 2 Jul 2026 11:49:42 -0700 Subject: [PATCH 20/44] test: update kb_search handler assertions for where_document param The handler now always forwards where_document (the regex/contains document-content filter) to kb.search; the three call-signature assertions predated that argument. Co-Authored-By: Claude Fable 5 --- tests/test_knowledge_server.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_knowledge_server.py b/tests/test_knowledge_server.py index 49924c8..b05d0d8 100644 --- a/tests/test_knowledge_server.py +++ b/tests/test_knowledge_server.py @@ -183,6 +183,7 @@ def test_search_passes_parameters(self, server, mock_kb): top_k=10, rerank=False, where=None, + where_document=None, ) def test_search_defaults(self, server, mock_kb): @@ -203,6 +204,7 @@ def test_search_defaults(self, server, mock_kb): top_k=5, rerank=None, # agent didn't specify → kb.default_rerank resolves it where=None, + where_document=None, ) def test_search_nonexistent_collection(self, server, mock_kb): @@ -815,6 +817,7 @@ def test_multi_collection_fanout(self, server, mock_kb): top_k=5, rerank=None, where=None, + where_document=None, ) def test_no_collection_returns_error(self, server): From 650d2b21287dfba4946d51c01c70cafa5b7042a1 Mon Sep 17 00:00:00 2001 From: aarontuor Date: Thu, 2 Jul 2026 12:17:06 -0700 Subject: [PATCH 21/44] test: close http_request coverage gap, drop 20s of wall-clock, fix mlflow deprecation - http_request was the only MCP tool with no handler-level test; add success/method-forwarding/transport-error cases with a mocked httpx.AsyncClient. - test_running_job held its worker in 'running' with a real time.sleep(10) that asyncio.run joined at loop shutdown; gate on a threading.Event released once the test observes the running state. - test_install_skill_routes_and_reports_missing built a real local-embedding KnowledgeBase it never used (the install path builds its own SkillRouter) and scanned the machine-global skill_sources cache; use a mock KB and point SKILL_SOURCES_DIR at a tmp dir. - mlflow.search_traces(experiment_ids=...) is deprecated in favor of locations= (FutureWarning under mlflow 3.11); switch the five call sites. Suite: 621 passed, 0 warnings, ~31s (was ~56s). Co-Authored-By: Claude Fable 5 --- tests/test_knowledge_server.py | 42 +++++++++++++-------- tests/test_registry_server.py | 69 +++++++++++++++++++++++++++++++++- tests/test_skill_tools.py | 17 +++++++-- tests/test_trace_scan.py | 2 +- tests/test_traces_cline.py | 2 +- tests/test_traces_codex.py | 2 +- tests/test_traces_goose.py | 2 +- tests/test_traces_opencode.py | 2 +- 8 files changed, 113 insertions(+), 25 deletions(-) diff --git a/tests/test_knowledge_server.py b/tests/test_knowledge_server.py index b05d0d8..0f35356 100644 --- a/tests/test_knowledge_server.py +++ b/tests/test_knowledge_server.py @@ -12,7 +12,7 @@ import asyncio import json -import time +import threading from unittest.mock import MagicMock import pytest @@ -424,27 +424,37 @@ def test_running_job(self, server, mock_kb, tmp_path): folder = tmp_path / "slow_docs" folder.mkdir() - # Make ingest block so the job stays in "running" + # Hold the job in "running" until the test has observed it. + # asyncio.run joins the worker thread at loop shutdown, so the + # event must be set before ``run()`` returns; the wait timeout + # only bounds a failing test. + release = threading.Event() + def blocking_ingest(*args, **kwargs): - time.sleep(10) + release.wait(timeout=10) return {"collection": "slow_docs", "files": 1, "chunks": 5} mock_kb.ingest.side_effect = blocking_ingest async def run(): - initial = await _call_tool_async( - server, - "kb_ingest", - { - "folder_path": str(folder), - }, - ) - assert initial["status"] == "started" - job_id = initial["job_id"] - - # Immediately check — should still be running - status = await _call_tool_async(server, "kb_job_status", {"job_id": job_id}) - assert status["status"] == "running" + try: + initial = await _call_tool_async( + server, + "kb_ingest", + { + "folder_path": str(folder), + }, + ) + assert initial["status"] == "started" + job_id = initial["job_id"] + + # Immediately check — should still be running + status = await _call_tool_async( + server, "kb_job_status", {"job_id": job_id} + ) + assert status["status"] == "running" + finally: + release.set() asyncio.run(run()) diff --git a/tests/test_registry_server.py b/tests/test_registry_server.py index 3d83b04..e389cc4 100644 --- a/tests/test_registry_server.py +++ b/tests/test_registry_server.py @@ -2,7 +2,7 @@ Tests for the registry MCP server. Tests tool handlers: save_code_spec, get_registry, search_registry, -read_file, run_command, install_dependencies. +read_file, run_command, http_request, install_dependencies. """ import subprocess @@ -10,6 +10,7 @@ from pathlib import Path from unittest.mock import patch, MagicMock +import httpx import pytest import yaml @@ -307,6 +308,72 @@ def test_timeout(self, server): assert "timed out" in text +# --------------------------------------------------------------------------- +# http_request +# --------------------------------------------------------------------------- + + +class TestHttpRequest: + + @patch("dsagt.mcp.registry_tools.httpx.AsyncClient") + def test_success_with_defaults(self, mock_client_cls, server): + """A plain URL issues a GET and returns status + body.""" + client = mock_client_cls.return_value.__aenter__.return_value + client.request.return_value = MagicMock(status_code=200, text="pong") + + text = call_tool( + server, + "http_request", + {"url": "https://example.test/ping"}, + ) + + assert text == "Status: 200\n\npong" + client.request.assert_awaited_once_with( + method="GET", + url="https://example.test/ping", + headers={}, + timeout=30.0, + ) + + @patch("dsagt.mcp.registry_tools.httpx.AsyncClient") + def test_method_and_headers_forwarded(self, mock_client_cls, server): + """Explicit method and headers reach the client unchanged.""" + client = mock_client_cls.return_value.__aenter__.return_value + client.request.return_value = MagicMock(status_code=201, text="created") + + text = call_tool( + server, + "http_request", + { + "url": "https://example.test/items", + "method": "POST", + "headers": {"Authorization": "Bearer tok"}, + }, + ) + + assert text.startswith("Status: 201") + client.request.assert_awaited_once_with( + method="POST", + url="https://example.test/items", + headers={"Authorization": "Bearer tok"}, + timeout=30.0, + ) + + @patch("dsagt.mcp.registry_tools.httpx.AsyncClient") + def test_transport_error_reported(self, mock_client_cls, server): + """httpx transport failures surface as a clean error string.""" + client = mock_client_cls.return_value.__aenter__.return_value + client.request.side_effect = httpx.ConnectError("Connection refused") + + text = call_tool( + server, + "http_request", + {"url": "https://example.test/down"}, + ) + + assert text == "Error making request: Connection refused" + + # --------------------------------------------------------------------------- # save_code_spec — dependency installation # --------------------------------------------------------------------------- diff --git a/tests/test_skill_tools.py b/tests/test_skill_tools.py index 84fa0a9..25bac9d 100644 --- a/tests/test_skill_tools.py +++ b/tests/test_skill_tools.py @@ -153,10 +153,21 @@ def test_search_skills_empty_catalog_hints_to_sync(self, tmp_path): class TestInstallSkill: - def test_install_skill_routes_and_reports_missing(self, tmp_path): + def test_install_skill_routes_and_reports_missing(self, tmp_path, monkeypatch): """install_skill is registered and reports a clean error when the - named skill isn't in any synced catalog.""" - server, skill_reg, kb = _make_skill_server(tmp_path) + named skill isn't in any synced catalog. + + The handler builds its own SkillRouter over the machine-global clone + cache, so point that at an empty tmp dir — the test must not depend on + (or scan) whatever catalogs are synced on the developer's machine. No + KB is involved on the install path, so a mock keeps the test fast. + """ + monkeypatch.setattr( + "dsagt.skills.SKILL_SOURCES_DIR", tmp_path / "skill_sources" + ) + server = create_skill_server( + kb=MagicMock(), runtime_dir=str(tmp_path / "runtime") + ) text = call_tool_sync( server, "install_skill", diff --git a/tests/test_trace_scan.py b/tests/test_trace_scan.py index f54eca1..be4d2b8 100644 --- a/tests/test_trace_scan.py +++ b/tests/test_trace_scan.py @@ -406,7 +406,7 @@ def test_emitted_traces_land_in_the_store(scan_env): collector.collect(include_last=True) # emit both turns exp = mlflow.get_experiment_by_name("proj") traces = mlflow.search_traces( - experiment_ids=[exp.experiment_id], return_type="list" + locations=[exp.experiment_id], return_type="list" ) sessions = {t.info.trace_metadata.get("mlflow.trace.session") for t in traces} assert len(traces) == 2 diff --git a/tests/test_traces_cline.py b/tests/test_traces_cline.py index e93de50..c2e26d6 100644 --- a/tests/test_traces_cline.py +++ b/tests/test_traces_cline.py @@ -164,6 +164,6 @@ def test_end_to_end_through_the_sink(mlflow_sqlite): assert len(ids) == 2 exp = mlflow.get_experiment_by_name("clineproj") traces = mlflow.search_traces( - experiment_ids=[exp.experiment_id], return_type="list" + locations=[exp.experiment_id], return_type="list" ) assert len(traces) == 2 diff --git a/tests/test_traces_codex.py b/tests/test_traces_codex.py index 1dd3985..4cbef4a 100644 --- a/tests/test_traces_codex.py +++ b/tests/test_traces_codex.py @@ -204,7 +204,7 @@ def test_end_to_end_through_the_sink(mlflow_sqlite): assert len(ids) == 2 # one MLflow trace per turn exp = mlflow.get_experiment_by_name("codexproj") traces = mlflow.search_traces( - experiment_ids=[exp.experiment_id], return_type="list" + locations=[exp.experiment_id], return_type="list" ) assert len(traces) == 2 # the tool-bearing turn rendered llm + tool spans under its agent root diff --git a/tests/test_traces_goose.py b/tests/test_traces_goose.py index be04a91..4cac896 100644 --- a/tests/test_traces_goose.py +++ b/tests/test_traces_goose.py @@ -162,6 +162,6 @@ def test_end_to_end_through_the_sink(mlflow_sqlite): assert len(ids) == 2 exp = mlflow.get_experiment_by_name("gooseproj") traces = mlflow.search_traces( - experiment_ids=[exp.experiment_id], return_type="list" + locations=[exp.experiment_id], return_type="list" ) assert len(traces) == 2 diff --git a/tests/test_traces_opencode.py b/tests/test_traces_opencode.py index 358b04a..93e4eaa 100644 --- a/tests/test_traces_opencode.py +++ b/tests/test_traces_opencode.py @@ -165,6 +165,6 @@ def test_end_to_end_through_the_sink(mlflow_sqlite): assert len(ids) == 2 exp = mlflow.get_experiment_by_name("ocproj") traces = mlflow.search_traces( - experiment_ids=[exp.experiment_id], return_type="list" + locations=[exp.experiment_id], return_type="list" ) assert len(traces) == 2 From bc3f581eca6c7e383c2f8a41b5343b35cbc5d41b Mon Sep 17 00:00:00 2001 From: aarontuor Date: Thu, 2 Jul 2026 12:19:24 -0700 Subject: [PATCH 22/44] test: remove unused imports (ruff F401/F811) Co-Authored-By: Claude Fable 5 --- tests/test_chroma_metadata.py | 1 - tests/test_dependency_integration.py | 2 -- tests/test_knowledge_base.py | 1 - tests/test_pipeline.py | 1 - tests/test_registry.py | 3 --- 5 files changed, 8 deletions(-) diff --git a/tests/test_chroma_metadata.py b/tests/test_chroma_metadata.py index 7504839..8edd0a3 100644 --- a/tests/test_chroma_metadata.py +++ b/tests/test_chroma_metadata.py @@ -7,7 +7,6 @@ """ import json -from pathlib import Path from unittest.mock import MagicMock, patch import numpy as np diff --git a/tests/test_dependency_integration.py b/tests/test_dependency_integration.py index 5a4dfce..d17cbcc 100644 --- a/tests/test_dependency_integration.py +++ b/tests/test_dependency_integration.py @@ -21,10 +21,8 @@ import subprocess import sys import textwrap -from pathlib import Path import pytest -import yaml from dsagt.mcp.registry_tools import create_registry_server diff --git a/tests/test_knowledge_base.py b/tests/test_knowledge_base.py index 846fd2a..a85be5c 100644 --- a/tests/test_knowledge_base.py +++ b/tests/test_knowledge_base.py @@ -850,7 +850,6 @@ def test_search_with_rerank(self, kb_with_data): mock_st = MagicMock() mock_st.CrossEncoder.return_value = mock_reranker - import sys with patch.dict(sys.modules, {"sentence_transformers": mock_st}): # Ensure the lazy import triggers diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 15633e6..129f9f3 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -5,7 +5,6 @@ import json from pathlib import Path -import pytest from dsagt.provenance import ( build_dependency_graph, diff --git a/tests/test_registry.py b/tests/test_registry.py index 1a4775f..4d27eb4 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -10,9 +10,6 @@ from dsagt.registry import ( CodeRegistry, - SkillRegistry, - _wrap_executable, - _uv_run_prefix, _parse_frontmatter, _lenient_frontmatter, render_arguments, From 29d459ba99662ddd43907b78875bf7135a1dc512 Mon Sep 17 00:00:00 2001 From: aarontuor Date: Thu, 2 Jul 2026 12:49:24 -0700 Subject: [PATCH 23/44] =?UTF-8?q?test(smoke):=20two-session=20overhaul=20?= =?UTF-8?q?=E2=80=94=20full=20capability=20coverage,=20stale=20OTel/csvtoo?= =?UTF-8?q?l=20era=20removed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Smoke test now runs two back-to-back dsagt start sessions and asserts 15 artifacts instead of 7: - Agent LLM-trace recovery is a HARD assertion (was informational, waiting on 'Phase 2' — the trace pipeline landed). The OTel block and its agent_otel_support helper are removed from source entirely (function, __all__, otel_payload_support class vars, docstring matrix) — the smoke script was the last consumer. - greet.py replaces the abandoned-csvtool fixture as a registerable AND executable code (stdlib-only, documented GRT-42 error code); the knowledge/ docs describe it, so registration, execution, provenance, and retrieval all reference one consistent tool. Execution is proven by greet's actual stdout in the trace_archive record; retrieval by the agent answering GRT-42 (only present in the ingested docs). - New coverage: skill catalog search+install, episodic memory (--episodic init, session_memory collection), code_use indexing, reconstruct_pipeline prompt, state.yaml session log + trace_source, dsagt info, and session 2's cross-session recall + startup catch-up. - ExplicitMemory now roots at /.dsagt/ as CLAUDE.md and the user docs document (the code was the outlier at project root). - Knowledge-ingest assert fixed to chroma_ids.json — route.json marks routed external collections, which ingest never creates. - Serverless staleness: run.sh header, cli.py 'MLflow ports' docstring. - manual_runs/ walkthroughs moved to tests/manual_walkthroughs/ (they document the dropped roo agent — kept off the docs site until refreshed); developer.md link updated. Verified end-to-end with --agent claude: 15/15 checks pass (the one first-run failure was the stale route.json assert, re-verified against the run's artifacts after the fix). Unit suite: 621 passed. Co-Authored-By: Claude Fable 5 --- docs/developer.md | 2 +- src/dsagt/agents/__init__.py | 40 +--- src/dsagt/agents/base.py | 21 -- src/dsagt/agents/claude.py | 1 - src/dsagt/agents/cline.py | 3 +- src/dsagt/agents/codex.py | 3 +- src/dsagt/agents/goose.py | 3 - src/dsagt/agents/opencode.py | 1 - src/dsagt/commands/cli.py | 5 +- src/dsagt/mcp/memory_tools.py | 11 +- .../cli_walkthrough.md | 0 .../vscode_walkthrough.md | 0 tests/smoke_test/greet.py | 14 +- tests/smoke_test/knowledge/DESCRIPTION.md | 5 +- tests/smoke_test/knowledge/api_reference.md | 39 ++-- tests/smoke_test/knowledge/installation.md | 15 +- tests/smoke_test/knowledge/troubleshooting.md | 29 ++- tests/smoke_test/run.sh | 202 +++++++++++------- tests/smoke_test/script.txt | 14 +- tests/smoke_test/script2.txt | 3 + 20 files changed, 213 insertions(+), 198 deletions(-) rename tests/{smoke_test/manual_runs => manual_walkthroughs}/cli_walkthrough.md (100%) rename tests/{smoke_test/manual_runs => manual_walkthroughs}/vscode_walkthrough.md (100%) create mode 100644 tests/smoke_test/script2.txt diff --git a/docs/developer.md b/docs/developer.md index 2ae0236..cb7afbe 100644 --- a/docs/developer.md +++ b/docs/developer.md @@ -9,7 +9,7 @@ uv run python -m pytest -m "not integration" # unit tests, no creds required uv run python -m pytest -m integration -v # integration tests (require real credentials / models) ``` -For per-flow hand-tests (CLI, VS Code extensions), see the scripts under [`tests/smoke_test/manual_runs/`](https://github.com/AI-ModCon/dsagt/tree/main/tests/smoke_test/manual_runs/). +For per-flow hand-tests (CLI, VS Code extensions), see the scripts under [`tests/manual_walkthroughs/`](https://github.com/AI-ModCon/dsagt/tree/main/tests/manual_walkthroughs/). ## Troubleshooting diff --git a/src/dsagt/agents/__init__.py b/src/dsagt/agents/__init__.py index a6957da..9641169 100644 --- a/src/dsagt/agents/__init__.py +++ b/src/dsagt/agents/__init__.py @@ -13,35 +13,10 @@ dirs. Model selection / API keys / provider base URLs are the user's responsibility. -The ``otel_payload_support`` class var records each agent's *potential* -native-OTel fidelity — retained as reference for the Phase 2 trace -pipeline, which reads transcripts rather than OTel: - - ========= ============ ============================================= - Agent Support tier Native MLflow visibility - ========= ============ ============================================= - claude full yes — every turn lands as a trace with - messages, response, tool_use blocks - (gated by 4 ``CLAUDE_CODE_*`` / - ``OTEL_LOG_*`` flags we set) - goose full yes — native OTel ``dispatch_tool_call`` span - (not consumed; no hook → no DSAGT autolog/memory) - codex partial limited — Codex OTel spans carry only token - counts and tool names - cline none no — Cline emits no OTel spans at all; - the agent is a black box from DSAgt's view - opencode none no — no OTel wired in by default - ========= ============ ============================================= - -Tool execution provenance (``dsagt-run`` ``tool.execute`` spans) and KB -observability (``kb.*`` / ``registry.*`` spans from MCP servers) always -work via OTLP — independent of the agent's own LLM-call traces. - Each agent's quirks live in its own module — see ``base.py`` for the :class:`AgentSetup` ABC and one of the subclass modules (``claude.py``, ``goose.py``, ``cline.py``, ``codex.py``, ``opencode.py``) -for the platform-specific details and the per-agent investigation -behind its support tier. +for the platform-specific details. Public API exported here: @@ -51,7 +26,6 @@ - :func:`static_agent_files_present` — has the static record been written? - :func:`dynamic_agent_record` — write runtime-dependent files. - :func:`launch_agent` — fork the agent and block until exit. -- :func:`agent_otel_support` — query an agent's OTel payload tier. """ from __future__ import annotations @@ -185,17 +159,6 @@ def agent_command(config: dict) -> list[str]: return _setup_for(config["agent"]).interactive_command(config) -def agent_otel_support(agent_name: str) -> str: - """Return the OTel-payload support tier for *agent_name*. - - See the module docstring matrix for the meaning of each tier. - Consumers (smoke-test, ``dsagt info``, future warning systems) use - this to decide whether to hard-fail or soft-warn when an agent's - LLM-call traces don't appear in MLflow. - """ - return _setup_for(agent_name).otel_payload_support - - def static_agent_record( config: dict, agent: str, @@ -280,7 +243,6 @@ def launch_agent( "AgentSetup", "agent_command", "agent_env", - "agent_otel_support", "dynamic_agent_record", "launch_agent", "static_agent_files_present", diff --git a/src/dsagt/agents/base.py b/src/dsagt/agents/base.py index 4295808..cad375d 100644 --- a/src/dsagt/agents/base.py +++ b/src/dsagt/agents/base.py @@ -304,27 +304,6 @@ class AgentSetup(ABC): #: that never read env-var credentials. credential_env_vars: ClassVar[tuple[str, ...]] = () - #: Whether this agent makes its LLM calls visible in MLflow natively. - #: Drives whether ``dsagt info`` / live audit / memory extraction see - #: the agent's reasoning + tool calls or just see the agent as a - #: black box. - #: - #: ``"full"`` — verified end-to-end (Claude Code, Goose). - #: Every agent turn lands in MLflow as a trace - #: with messages + response + tool_use blocks. - #: ``"partial"`` — agent emits OTel but spans don't carry - #: message content (Codex: only token counts + - #: tool names). - #: ``"none"`` — agent emits no payload-bearing OTel traces, or - #: emits only metrics without payloads (Cline, - #: Roo Code). The agent is a black box from - #: DSAgt's perspective; tool execution + KB - #: observability still work regardless via - #: dsagt-run / MCP-server spans. - #: - #: See agents/.py docstrings for the per-agent investigation. - otel_payload_support: ClassVar[str] = "full" - #: Directory (relative to the working dir) the agent natively auto-discovers #: ``SKILL.md`` skill folders from. ``setup_skills`` mirrors installed #: (bundled + project) skills here so the agent discovers/auto-invokes them diff --git a/src/dsagt/agents/claude.py b/src/dsagt/agents/claude.py index 4f26a24..0a3640f 100644 --- a/src/dsagt/agents/claude.py +++ b/src/dsagt/agents/claude.py @@ -46,7 +46,6 @@ class ClaudeSetup(AgentSetup): "ANTHROPIC_BASE_URL", "ANTHROPIC_MODEL", ) - otel_payload_support = "full" credential_hints = ( ("ANTHROPIC_API_KEY", "your Anthropic API key (skip if subscription-authed)"), ("ANTHROPIC_BASE_URL", "optional gateway / proxy URL"), diff --git a/src/dsagt/agents/cline.py b/src/dsagt/agents/cline.py index ae4dcfc..4a14b67 100644 --- a/src/dsagt/agents/cline.py +++ b/src/dsagt/agents/cline.py @@ -28,7 +28,7 @@ the UI exposes ``awsBedrockEndpoint`` etc.) can drive the project manually. -OTel support: **none** (verified, ``otel_payload_support = "none"``). +OTel support: **none** (verified). Cline ships ``@opentelemetry/*`` packages but installs only a ``MeterProvider`` + ``LoggerProvider`` — never a ``TracerProvider``; zero spans are ever created (``OpenTelemetryClientProvider.ts``). Its @@ -72,7 +72,6 @@ class ClineSetup(AgentSetup): # is harmless if unused, and search_skills covers the disabled case. native_skills_dir = ".cline/skills" install_hint = "Install with `npm i -g cline`." - otel_payload_support = "none" # Cline's CLI nominally supports openai-native + anthropic, but cline # auth's ``-b/--baseurl`` flag is openai-only and the openai-native # path needs a non-standard model env var. Anthropic is the only diff --git a/src/dsagt/agents/codex.py b/src/dsagt/agents/codex.py index c097e91..599a68a 100644 --- a/src/dsagt/agents/codex.py +++ b/src/dsagt/agents/codex.py @@ -16,7 +16,7 @@ (or via ``OPENAI_API_KEY`` / ``OPENAI_BASE_URL`` env). We don't write a model_providers block. -OTel support: **partial** (verified, ``otel_payload_support = "partial"``). +OTel support: **partial** (verified). Codex's ``codex-otel`` Rust crate emits OTel spans/logs/metrics, BUT: * LLM-call spans (``stream_request``, ``handle_responses``) carry @@ -96,7 +96,6 @@ class CodexSetup(AgentSetup): install_hint = ( "Install with `npm i -g @openai/codex` or " "`brew install --cask codex`." ) - otel_payload_support = "partial" # Codex is openai-protocol native. credential_env_vars = ("OPENAI_API_KEY", "OPENAI_BASE_URL") credential_hints = ( diff --git a/src/dsagt/agents/goose.py b/src/dsagt/agents/goose.py index e00a1f2..c9a36df 100644 --- a/src/dsagt/agents/goose.py +++ b/src/dsagt/agents/goose.py @@ -45,9 +45,6 @@ class GooseSetup(AgentSetup): static_marker = ".goosehints" native_skills_dir = ".agents/skills" # cross-agent standard goose discovers install_hint = "See https://github.com/block/goose for installation." - # Goose emits OTel natively, but DSAGT no longer forces/consumes it — and - # goose has no hook, so it gets no autolog / episodic-memory options. - otel_payload_support = "full" # Goose's openai/anthropic providers read ``OPENAI_HOST`` / ``ANTHROPIC_HOST`` # for the base URL (goose-specific naming), plus its own GOOSE_PROVIDER / # GOOSE_MODEL routing selectors. diff --git a/src/dsagt/agents/opencode.py b/src/dsagt/agents/opencode.py index 098f820..002dd7e 100644 --- a/src/dsagt/agents/opencode.py +++ b/src/dsagt/agents/opencode.py @@ -116,7 +116,6 @@ class OpenCodeSetup(AgentSetup): base_command = ["opencode"] static_marker = "AGENTS.md" install_hint = "Install with `npm i -g opencode-ai`." - otel_payload_support = "none" # OpenCode reads provider creds via ``{env:VAR}`` interpolation in # its config — the file references these vars, opencode resolves # them from the user's shell at runtime. Same shape as goose's diff --git a/src/dsagt/commands/cli.py b/src/dsagt/commands/cli.py index 3bfab73..ece9bbc 100644 --- a/src/dsagt/commands/cli.py +++ b/src/dsagt/commands/cli.py @@ -554,8 +554,9 @@ def _cmd_smoke_test(args): With ``--all``, run the harness in parallel for every agent in ``VALID_AGENTS``. Each agent has its own project name (``smoke-test-X``) - so they don't collide on MLflow ports, kb_index, or registry entries. - Output is per-agent log files; the summary prints in finish order. + so they don't collide on the sqlite MLflow store, kb_index, or registry + entries. Output is per-agent log files; the summary prints in finish + order. """ pkg_dir = Path(__file__).resolve().parent.parent.parent.parent script = pkg_dir / "tests" / "smoke_test" / "run.sh" diff --git a/src/dsagt/mcp/memory_tools.py b/src/dsagt/mcp/memory_tools.py index e6cf161..1b515a2 100644 --- a/src/dsagt/mcp/memory_tools.py +++ b/src/dsagt/mcp/memory_tools.py @@ -101,12 +101,13 @@ def _memory_tools_and_handlers( """Build the explicit-memory ``(tool defs, handler map)``. Combined with the other concern modules' tools under one MCP ``Server`` by - :func:`dsagt.mcp.server.create_dsagt_server`. ``ExplicitMemory`` is rooted - at ``runtime_dir`` (falling back to the KB index's parent), matching the - project's ``.dsagt`` memory location. + :func:`dsagt.mcp.server.create_dsagt_server`. ``ExplicitMemory`` lives in + ``/.dsagt/`` alongside config.yaml and state.yaml — the + server-owned internals — with ``runtime_dir`` (falling back to the KB + index's parent) as the project dir. """ - mem_dir = Path(runtime_dir) if runtime_dir else kb.index_dir.parent - memory = ExplicitMemory(runtime_dir=mem_dir) + project_dir = Path(runtime_dir) if runtime_dir else kb.index_dir.parent + memory = ExplicitMemory(runtime_dir=project_dir / ".dsagt") handlers = { "kb_remember": partial(_handle_kb_remember, kb=kb, memory=memory), diff --git a/tests/smoke_test/manual_runs/cli_walkthrough.md b/tests/manual_walkthroughs/cli_walkthrough.md similarity index 100% rename from tests/smoke_test/manual_runs/cli_walkthrough.md rename to tests/manual_walkthroughs/cli_walkthrough.md diff --git a/tests/smoke_test/manual_runs/vscode_walkthrough.md b/tests/manual_walkthroughs/vscode_walkthrough.md similarity index 100% rename from tests/smoke_test/manual_runs/vscode_walkthrough.md rename to tests/manual_walkthroughs/vscode_walkthrough.md diff --git a/tests/smoke_test/greet.py b/tests/smoke_test/greet.py index 30f83b0..0fa72cc 100755 --- a/tests/smoke_test/greet.py +++ b/tests/smoke_test/greet.py @@ -1,7 +1,15 @@ -"""Simple greeting script for smoke testing.""" +"""greet — the smoke test's registerable, executable fixture CLI. + +Stdlib-only so registration + execution via dsagt-run needs no +dependency install. The GRT-42 empty-name error code is documented in +knowledge/troubleshooting.md; the smoke script asks the agent to +retrieve it from the knowledge collection, so keep code and docs in +sync. +""" import argparse import json +import sys parser = argparse.ArgumentParser(description="Generate a greeting") parser.add_argument("name", help="Name to greet") @@ -10,5 +18,9 @@ ) args = parser.parse_args() +if not args.name.strip(): + print(json.dumps({"status": "error", "code": "GRT-42", "error": "empty name"})) + sys.exit(1) + result = {"message": f"{args.greeting}, {args.name}!", "status": "ok"} print(json.dumps(result, indent=2)) diff --git a/tests/smoke_test/knowledge/DESCRIPTION.md b/tests/smoke_test/knowledge/DESCRIPTION.md index 1ec721d..08f904b 100644 --- a/tests/smoke_test/knowledge/DESCRIPTION.md +++ b/tests/smoke_test/knowledge/DESCRIPTION.md @@ -1,2 +1,5 @@ Sample documentation for smoke testing the DSAGT knowledge base. -Covers a fictional "csvtool" CLI for CSV data processing. +Covers the "greet" CLI fixture (tests/smoke_test/greet.py) — the same +utility the smoke script has the agent register and execute, so the +ingested docs, the registered code, and the provenance records all +describe one consistent tool. diff --git a/tests/smoke_test/knowledge/api_reference.md b/tests/smoke_test/knowledge/api_reference.md index 18cd7a9..d74b21e 100644 --- a/tests/smoke_test/knowledge/api_reference.md +++ b/tests/smoke_test/knowledge/api_reference.md @@ -1,35 +1,28 @@ -# csvtool CLI Reference +# greet CLI Reference -## csvtool filter - -Filter rows by column value. +Generate a JSON greeting for a name. ```bash -csvtool filter --input data.csv --column status --value active --output filtered.csv +python greet.py NAME [--greeting WORD] ``` Parameters: -- `--input` (required) — Input CSV file path -- `--column` (required) — Column name to filter on -- `--value` (required) — Value to match -- `--output` (required) — Output CSV file path +- `NAME` (positional, required) — Name to greet. +- `--greeting` (optional) — Greeting word. Default: `Hello`. -## csvtool validate +## Output -Check a CSV against a schema. +On success, prints a JSON object to stdout and exits 0: -```bash -csvtool validate --input data.csv --schema schema.json +```json +{ + "message": "Hello, DSAGT!", + "status": "ok" +} ``` -Outputs a JSON report with row count, null counts per column, type violations, and an overall pass/fail status. - -## csvtool summary - -Print column statistics. - -```bash -csvtool summary --input data.csv -``` +## Exit codes -Outputs: column names, types, null counts, unique value counts, and min/max for numeric columns. +- `0` — success. +- `1` — invalid input. The JSON output carries `"status": "error"` and an + error `code` field (see troubleshooting). diff --git a/tests/smoke_test/knowledge/installation.md b/tests/smoke_test/knowledge/installation.md index ab83d92..09f225e 100644 --- a/tests/smoke_test/knowledge/installation.md +++ b/tests/smoke_test/knowledge/installation.md @@ -1,11 +1,12 @@ -# csvtool Installation +# greet Installation -Install csvtool via pip: +greet ships as a single-file script (`greet.py`) with the smoke test +fixtures — there is nothing to install. -```bash -pip install csvtool -``` +Requirements: Python 3.10+, standard library only (argparse, json, sys). -Requirements: Python 3.10+, pandas >= 2.0. +Run it directly: -csvtool provides CLI commands for filtering, validating, and summarizing CSV files. +```bash +python greet.py World --greeting Hi +``` diff --git a/tests/smoke_test/knowledge/troubleshooting.md b/tests/smoke_test/knowledge/troubleshooting.md index 94c4af1..f40c62e 100644 --- a/tests/smoke_test/knowledge/troubleshooting.md +++ b/tests/smoke_test/knowledge/troubleshooting.md @@ -1,18 +1,25 @@ -# csvtool Troubleshooting +# greet Troubleshooting -## Encoding errors +## Error GRT-42: empty name -If csvtool raises a UnicodeDecodeError, the file may not be UTF-8. Use `--encoding latin-1` or detect encoding with `chardet`. +When the `NAME` argument is empty or whitespace-only, greet prints a JSON +error object with error code `GRT-42` and exits 1: -## Large file performance +```json +{"status": "error", "code": "GRT-42", "error": "empty name"} +``` -For files over 1 GB, csvtool streams rows in chunks. If you still run out of memory, increase the chunk size with `--chunk-size 100000` or filter to specific columns with `--columns col1,col2`. +Fix: pass a non-empty name. Shell quoting is the usual culprit — an unset +variable like `"$USER_NAME"` expands to an empty string. -## Schema validation failures +## Output is not valid JSON -The validate command checks column types against a JSON schema. Common issues: -- Mixed types in a column (e.g., "123" and "abc" in an integer column) -- Missing required columns -- Null values in non-nullable columns +greet writes only JSON to stdout. If you see extra text mixed in, another +tool in your pipeline is writing to the same stream — redirect its output +to stderr. -Fix the data or update the schema to match reality. +## Wrong greeting word + +`--greeting` must come after the name or be joined with `=` +(`--greeting=Ahoy`); a bare `--greeting` at the end consumes the name as +its value and fails with a missing-argument error. diff --git a/tests/smoke_test/run.sh b/tests/smoke_test/run.sh index c6fe522..4389bf4 100755 --- a/tests/smoke_test/run.sh +++ b/tests/smoke_test/run.sh @@ -2,8 +2,16 @@ # DSAGT smoke test — non-interactive end-to-end exercise. # # Drives the SAME `dsagt start` lifecycle as an interactive run (config -# generation → start_services → agent → run_extraction → stop_services). -# Only the agent-launch step swaps from `goose session` to `goose run -i`. +# generation → agent in the foreground → post-session run_extraction). +# Serverless: there are no services to start or stop — all self-logging +# lands in the project's sqlite MLflow store. Only the agent-launch +# step swaps from interactive to batch (`--script`). +# +# TWO sessions run back-to-back: session 1 exercises ingest, code +# registration + execution, provenance, KB retrieval, skill install, +# and explicit memory; session 2 exercises cross-session recall, +# registry persistence, and the startup catch-up path. +# # BYOA: the user's shell must already have the agent's provider creds # (per `dsagt init` hints). No .env handling. # @@ -23,7 +31,6 @@ AGENT="${DSAGT_SMOKE_AGENT:-${1:-goose}}" # arg or env var, default goose PROJECT="smoke-test-${AGENT}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" DSAGT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" -SCRIPT_FILE="${SCRIPT_DIR}/script.txt" # Project lives at the default ``dsagt init`` location so smoke-test # artifacts stay out of the dsagt source tree. PDIR mirrors # DEFAULT_PROJECTS_BASE in src/dsagt/session.py. @@ -42,7 +49,7 @@ echo "[smoke] Agent: ${AGENT}" cd "${DSAGT_ROOT}" # --------------------------------------------------------------------------- -# 2. Clean slate (idempotent — silent if nothing exists) +# 1. Clean slate (idempotent — silent if nothing exists) # --------------------------------------------------------------------------- dsagt rm "${PROJECT}" -y >/dev/null 2>&1 || true rm -rf "${PDIR}" @@ -59,61 +66,87 @@ if [[ "${AGENT}" == "claude" ]]; then fi # --------------------------------------------------------------------------- -# 3. Init at the default ``~/dsagt-projects/`` location so smoke artifacts -# don't pollute the dsagt source tree. The agent's cwd will be -# ``${PDIR}``; the script template uses ``{{SMOKE_DIR}}`` placeholders -# that we substitute below so prompt paths resolve regardless of where -# PDIR lives. +# 2. Init at the default ``~/dsagt-projects/`` location so smoke artifacts +# don't pollute the dsagt source tree. --episodic so session turns land +# in the ``session_memory`` collection (asserted below). The default KB +# set includes the genesis skill catalog, which the skill-install prompt +# relies on. # --------------------------------------------------------------------------- # Force the non-interactive (flag-driven) init path regardless of TTY by # closing stdin — `dsagt init` prompts only when stdin is a TTY. -dsagt init "${PROJECT}" --agent "${AGENT}" < /dev/null +dsagt init "${PROJECT}" --agent "${AGENT}" --episodic < /dev/null # Substitute {{SMOKE_DIR}} → absolute smoke_test/ path before the agent -# sees the script. Prompts can then reference data/knowledge files via +# sees the scripts. Prompts can then reference data/knowledge files via # absolute paths regardless of the agent's cwd. -RENDERED_SCRIPT=$(mktemp -t dsagt-smoke-script.XXXXXX) -trap 'rm -f "${RENDERED_SCRIPT}"' EXIT -sed "s|{{SMOKE_DIR}}|${SCRIPT_DIR}|g" "${SCRIPT_FILE}" > "${RENDERED_SCRIPT}" +RENDERED_SCRIPT_1=$(mktemp -t dsagt-smoke-script1.XXXXXX) +RENDERED_SCRIPT_2=$(mktemp -t dsagt-smoke-script2.XXXXXX) +SESSION_LOG_1=$(mktemp -t dsagt-smoke-log1.XXXXXX) +SESSION_LOG_2=$(mktemp -t dsagt-smoke-log2.XXXXXX) +trap 'rm -f "${RENDERED_SCRIPT_1}" "${RENDERED_SCRIPT_2}" "${SESSION_LOG_1}" "${SESSION_LOG_2}"' EXIT +sed "s|{{SMOKE_DIR}}|${SCRIPT_DIR}|g" "${SCRIPT_DIR}/script.txt" > "${RENDERED_SCRIPT_1}" +sed "s|{{SMOKE_DIR}}|${SCRIPT_DIR}|g" "${SCRIPT_DIR}/script2.txt" > "${RENDERED_SCRIPT_2}" # --------------------------------------------------------------------------- -# 4. Run the FULL `dsagt start` lifecycle, with the agent in batch mode. -# Wall-clock cap belt-and-suspenders the --max-turns inside. +# 3. Session runner: the FULL `dsagt start` lifecycle with the agent in +# batch mode, under a wall-clock watchdog. # # Pure-bash watcher pattern instead of GNU `timeout` so the smoke test # works on stock macOS without `brew install coreutils`. SIGTERM gives -# dsagt's finally-block a chance to stop services cleanly; the +# dsagt's finally-block a chance to run post-session extraction; the # follow-up SIGKILL after WALL_CLOCK_GRACE catches the agent if it # swallows the term signal. +# +# Output tees to a per-session log — the retrieval and recall +# assertions grep it for facts the agent can only have gotten from +# the KB / memory (process substitution keeps $! on dsagt itself). # --------------------------------------------------------------------------- -WALL_CLOCK_CAP=300 # seconds (5 minutes) WALL_CLOCK_GRACE=10 # extra seconds before SIGKILL +run_session() { + local script_file="$1" max_turns="$2" cap="$3" log_file="$4" + dsagt start "${PROJECT}" --script "${script_file}" --max-turns "${max_turns}" \ + > >(tee "${log_file}") 2>&1 & + local pid=$! + ( + sleep "${cap}" + kill -TERM "${pid}" 2>/dev/null && \ + echo "[smoke] WARN: ${cap}s cap exceeded — sent SIGTERM to dsagt start (pid ${pid})" + sleep "${WALL_CLOCK_GRACE}" + kill -KILL "${pid}" 2>/dev/null && \ + echo "[smoke] WARN: dsagt start did not exit on SIGTERM — sent SIGKILL" + ) & + local watcher=$! + wait "${pid}" + local rc=$? + # Tear down the watcher if dsagt exited on its own. + kill -TERM "${watcher}" 2>/dev/null + wait "${watcher}" 2>/dev/null + # Let the tee process-substitution drain before the log is grepped. + sleep 1 + return "${rc}" +} + echo -echo "[smoke] Running dsagt start --script (${WALL_CLOCK_CAP}s wall-clock cap)…" -dsagt start "${PROJECT}" --script "${RENDERED_SCRIPT}" --max-turns 30 & -DSAGT_PID=$! -( - sleep "${WALL_CLOCK_CAP}" - kill -TERM "${DSAGT_PID}" 2>/dev/null && \ - echo "[smoke] WARN: ${WALL_CLOCK_CAP}s cap exceeded — sent SIGTERM to dsagt start (pid ${DSAGT_PID})" - sleep "${WALL_CLOCK_GRACE}" - kill -KILL "${DSAGT_PID}" 2>/dev/null && \ - echo "[smoke] WARN: dsagt start did not exit on SIGTERM — sent SIGKILL" -) & -WATCHER_PID=$! -wait "${DSAGT_PID}" +echo "[smoke] Session 1: ingest / register / execute / provenance / skills / memory…" +run_session "${RENDERED_SCRIPT_1}" 40 420 "${SESSION_LOG_1}" START_EXIT=$? -# Tear down the watcher if dsagt exited on its own. -kill -TERM "${WATCHER_PID}" 2>/dev/null -wait "${WATCHER_PID}" 2>/dev/null - if [[ ${START_EXIT} -ne 0 ]]; then echo "WARN: dsagt start exited non-zero (${START_EXIT}) — continuing to artifact checks anyway" fi -# Serverless: ``dsagt start`` runs the agent in the foreground and owns no -# background services, so there's nothing to reap here. +# --------------------------------------------------------------------------- +# 4. Session 2: cross-session recall + registry persistence. Its startup +# also runs the catch-up path over session 1 (code-use indexing + the +# pinned trace re-collect), so the post-session-2 assertions cover it. +# --------------------------------------------------------------------------- +echo +echo "[smoke] Session 2: cross-session recall + catch-up…" +run_session "${RENDERED_SCRIPT_2}" 15 240 "${SESSION_LOG_2}" +START_EXIT_2=$? +if [[ ${START_EXIT_2} -ne 0 ]]; then + echo "WARN: session 2 dsagt start exited non-zero (${START_EXIT_2}) — continuing to artifact checks anyway" +fi # --------------------------------------------------------------------------- # 5. Artifact checks @@ -131,40 +164,67 @@ check() { fi } -check "csvtool_filter spec written" "test -f '${PDIR}/codes/csvtool_filter.md'" -check "trace_archive has records" "ls '${PDIR}/trace_archive/'*.json | grep -q ." -check "scan_directory record" "ls '${PDIR}/trace_archive/'*scan_directory*.json | grep -q ." +# -- registry + execution + provenance -------------------------------------- +check "greet spec written" "test -f '${PDIR}/codes/greet.md'" +# The execution went through dsagt-run iff the record captured greet's +# actual stdout — an agent that ran the script by hand can't fake the +# trace_archive record. +check "greet executed via dsagt-run" "grep -l 'Ahoy, DSAGT' '${PDIR}/trace_archive/'*greet*.json" +check "greet re-run in session 2" "test \$(ls '${PDIR}/trace_archive/'*greet*.json | wc -l) -ge 2" +check "scan_directory record" "ls '${PDIR}/trace_archive/'*scan_directory*.json" + +# -- knowledge base ---------------------------------------------------------- # Both files are written by dsagt-server's kb_ingest MCP tool — chroma.sqlite3 -# is the actual vector DB, route.json is the collection manifest. Checking -# only `test -d kb_index/knowledge` is too weak: an agent can satisfy it by -# hand-crafting an empty directory tree, masking a broken MCP wiring (which is -# exactly what we hit when cline's dsagt server crashed silently and the LLM -# compensated by mkdir-ing the path). -check "knowledge ingested (route)" "test -f '${PDIR}/kb_index/knowledge/route.json'" +# is the actual vector DB, chroma_ids.json the internal-collection manifest +# (route.json marks routed *external* collections, which ingest never +# creates). Checking only `test -d kb_index/knowledge` is too weak: an agent +# can satisfy it by hand-crafting an empty directory tree, masking a broken +# MCP wiring (which is exactly what we hit when cline's dsagt server crashed +# silently and the LLM compensated by mkdir-ing the path). +check "knowledge ingested (ids)" "test -f '${PDIR}/kb_index/knowledge/chroma_ids.json'" check "knowledge ingested (vectors)" "test -f '${PDIR}/kb_index/knowledge/chroma.sqlite3'" -# Explicit memory writes to /explicit_memories.yaml (YAML at the -# project root), NOT to kb_index/. Only kb_remember (called deliberately -# by the agent in response to "Put this in explicit memory" / "remember -# this") populates the file. End-of-session episodic extraction writes -# elsewhere (kb_index/episodic_memory/...) and is independent. Checking -# the YAML's existence + non-empty catches the hallucination case where -# the agent claims it stored a fact but didn't actually call the tool. -check "explicit memory recorded" "test -s '${PDIR}/explicit_memories.yaml'" +# GRT-42 lives only in knowledge/troubleshooting.md — the agent answering +# with it proves retrieval reached the ingested docs. +check "kb retrieval answered (GRT-42)" "grep -q 'GRT-42' '${SESSION_LOG_1}'" + +# -- skills ------------------------------------------------------------------ +check "catalog skill installed" "ls '${PDIR}/skills/'*/SKILL.md" + +# -- memory ------------------------------------------------------------------ +# Explicit memory lives with the server-owned internals in .dsagt/. Only +# kb_remember (called deliberately by the agent in response to "Put this in +# explicit memory") populates the file; checking non-empty catches the +# hallucination case where the agent claims it stored a fact but didn't +# actually call the tool. +check "explicit memory recorded" "test -s '${PDIR}/.dsagt/explicit_memories.yaml'" +# Cross-session recall: session 2's answer must carry the stored fact's +# tokens, which only kb_get_memories (or episodic retrieval) can supply — +# session 2 never saw samples.csv. +check "cross-session recall" "grep -qi 'null' '${SESSION_LOG_2}' && grep -qi 'status' '${SESSION_LOG_2}'" +# Episodic memory (enabled via --episodic) chunks+embeds every turn into +# the session_memory collection on the heartbeat. +check "episodic memory indexed" "test -f '${PDIR}/kb_index/session_memory/chroma.sqlite3'" + +# -- observability + session state ------------------------------------------- check "mlflow store has traces" "test -s '${PDIR}/mlflow.db'" +# The heartbeat indexes trace_archive/ execution records into the code_use +# collection (plus a startup catch-up in session 2). +check "code_use collection indexed" "test -f '${PDIR}/kb_index/code_use/chroma.sqlite3'" +# state.yaml is the anchor for crash catch-up: both sessions logged, and +# session 1 carries the trace_source token the session-2 catch-up pinned. +check "state.yaml logged 2 sessions" "uv run --quiet python -c \" +import yaml, sys +s = yaml.safe_load(open('${PDIR}/.dsagt/state.yaml')) +sessions = s.get('sessions') or [] +sys.exit(0 if len(sessions) >= 2 and sessions[0].get('trace_source') else 1)\"" +check "dsagt info runs" "dsagt info '${PROJECT}'" # --------------------------------------------------------------------------- -# 6. Agent LLM-call observability: informational only in Phase 1. -# DSAGT no longer forces native agent OTel emission (the OTLP-routing -# env + telemetry flags were removed) — agent LLM-call history is -# recovered post-hoc from the on-disk transcript, which lands in -# Phase 2's TracePipeline. Until then only claude (via the -# ``mlflow autolog claude`` Stop hook over its transcript) and DSAGT's -# own MCP / dsagt-run spans populate the serverless ``mlflow.db`` store. -# So we report the agent-trace count but never FAIL on it here. +# 6. Agent LLM-call transparency: the trace pipeline recovers every agent's +# turns from its on-disk transcript (heartbeat + graceful-shutdown flush, +# backstopped by session 2's startup catch-up), so agent traces in the +# store are a hard requirement for all five agents. # --------------------------------------------------------------------------- -AGENT_OTEL_SUPPORT=$(uv run --quiet python -c "from dsagt.agents import agent_otel_support; print(agent_otel_support('${AGENT}'))" 2>/dev/null) -AGENT_OTEL_SUPPORT="${AGENT_OTEL_SUPPORT:-unknown}" - AGENT_TRACES=$(uv run --quiet python </dev/null import mlflow mlflow.set_tracking_uri("sqlite:///${PDIR}/mlflow.db") @@ -193,14 +253,7 @@ print(n) PY ) AGENT_TRACES="${AGENT_TRACES:-0}" - -# Phase 1: informational only — native OTel harvest was removed, transcript -# capture lands in Phase 2. Never FAIL on the agent-trace count here. -if [[ "${AGENT_TRACES}" -gt 0 ]]; then - echo " INFO agent transparency: ${AGENT_TRACES} agent trace(s) in the mlflow.db store (claude autolog / Phase-2 transcript capture)" -else - echo " INFO agent transparency: 0 agent LLM-call traces yet (${AGENT}, tier=${AGENT_OTEL_SUPPORT}) — restored by Phase 2's transcript pipeline" -fi +check "agent traces recovered (${AGENT_TRACES})" "test '${AGENT_TRACES}' -gt 0" echo if [[ ${FAIL} -eq 0 ]]; then @@ -208,5 +261,8 @@ if [[ ${FAIL} -eq 0 ]]; then exit 0 else echo "[smoke] FAIL" + echo "[smoke] session logs kept: ${SESSION_LOG_1} ${SESSION_LOG_2}" + trap - EXIT + rm -f "${RENDERED_SCRIPT_1}" "${RENDERED_SCRIPT_2}" exit 1 fi diff --git a/tests/smoke_test/script.txt b/tests/smoke_test/script.txt index b906147..906151f 100644 --- a/tests/smoke_test/script.txt +++ b/tests/smoke_test/script.txt @@ -1,11 +1,15 @@ Ingest the docs in {{SMOKE_DIR}}/knowledge/ into a collection named knowledge. -I have a CSV utility called csvtool. Its reference is at {{SMOKE_DIR}}/knowledge/api_reference.md — please register the filter subcommand so we can reuse it later. Use an underscore in the name, not a hyphen. +I have a small CLI utility at {{SMOKE_DIR}}/greet.py — its reference doc is in the knowledge collection you just ingested. Register it in the code registry as "greet" so we can reuse it later. -Use the scan_directory tool from the registry to scan the {{SMOKE_DIR}}/data/ directory so I can see what's in it. +Run the registered greet code to greet "DSAGT" with the greeting "Ahoy". -Look at {{SMOKE_DIR}}/data/samples.csv and give me a short summary — columns, row count, any obvious quality issues. +Use the scan_directory code from the registry to scan the {{SMOKE_DIR}}/data/ directory so I can see what's in it. -Put this fact in explicit memory: samples.csv has null values in the status and timestamp columns. +Search the knowledge collection: what error code does greet report when the name argument is empty? Reply with the code verbatim. + +Reconstruct the pipeline of code executions run in this project so far. -What do you remember about the samples dataset? +Search the external skill catalog for a skill that helps with literature search, and install the best match into this project. + +Put this fact in explicit memory: samples.csv has null values in the status and timestamp columns. diff --git a/tests/smoke_test/script2.txt b/tests/smoke_test/script2.txt new file mode 100644 index 0000000..b1f6d3d --- /dev/null +++ b/tests/smoke_test/script2.txt @@ -0,0 +1,3 @@ +What do you remember about the samples dataset? + +Run the registered greet code once more, greeting "again". From c040bddfe6d762398c073c08f23be308b99398b8 Mon Sep 17 00:00:00 2001 From: aarontuor Date: Thu, 2 Jul 2026 13:12:49 -0700 Subject: [PATCH 24/44] fix(traces): CodexReader reads the per-project CODEX_HOME; smoke hardening from the 4-agent sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running the smoke test across codex/goose/cline/opencode surfaced one product bug and three harness issues: - CodexReader scanned the global ~/.codex/sessions/, but DSAGT always launches codex with CODEX_HOME=/.codex-data (its MCP config lives there), so rollouts land project-local and the live trace pipeline collected NOTHING for codex — no MLflow traces, no episodic memory, no trace_source stamp for crash catch-up. Default the reader to /.codex-data/sessions. Verified end-to-end: codex smoke now 15/15. - The smoke agent-trace check masked that bug: its service.name span heuristic counted internal DSAGT spans lacking the attribute as agent traces. Count MLflowSink's positive dsagt.trace_id trace-metadata marker instead (validated per-store: claude 1, goose 5, opencode 1, broken-codex 0). - cline: dsagt start --script hard-errors by design (agents/cline.py), so the harness SKIPs cline with that explanation instead of 15 red checks. - greet execution: goose put 'Ahoy' in both arg slots and opencode ran greet through bare bash (bypassing dsagt-run) — the prompt now names both arguments and steers to the spec's dsagt-run command, and the assert matches the greeting prefix only. Opencode passes with the steered prompt. Sweep verdicts: claude 15/15, codex 15/15, opencode 15/15, goose 14/15→pass (both changed checks verified against its artifacts), cline SKIP. Unit suite: 621 passed. Co-Authored-By: Claude Fable 5 --- src/dsagt/traces.py | 16 ++++++++------ tests/smoke_test/run.sh | 42 ++++++++++++++++++++++--------------- tests/smoke_test/script.txt | 2 +- 3 files changed, 36 insertions(+), 24 deletions(-) diff --git a/src/dsagt/traces.py b/src/dsagt/traces.py index 637a3d5..7818bf1 100644 --- a/src/dsagt/traces.py +++ b/src/dsagt/traces.py @@ -437,21 +437,25 @@ def active_file(self) -> Path | None: return max(files, key=lambda p: p.stat().st_mtime) if files else None -_CODEX_SESSIONS_ROOT = Path.home() / ".codex" / "sessions" - - class CodexReader(JsonlReader): """The newest ``rollout-*.jsonl`` whose ``session_meta.cwd`` is this project. - Codex rollouts live globally under ``~/.codex/sessions/YYYY/MM/DD/``; each - opens with a ``session_meta`` record carrying the launch ``cwd``. + DSAGT always runs codex with ``CODEX_HOME=/.codex-data`` (its MCP + config lives there — see agents/codex.py), so rollouts land under + ``/.codex-data/sessions/YYYY/MM/DD/``, not the global + ``~/.codex/sessions/``. Each rollout opens with a ``session_meta`` record + carrying the launch ``cwd``; the filter guards against stray files. """ agent = "codex" def __init__(self, project_dir, *, sessions_root: Path | None = None): self._project_dir = os.path.abspath(project_dir) - self._root = Path(sessions_root) if sessions_root else _CODEX_SESSIONS_ROOT + self._root = ( + Path(sessions_root) + if sessions_root + else Path(self._project_dir) / ".codex-data" / "sessions" + ) def _rollout_cwd(self, path: Path) -> str | None: with open(path, encoding="utf-8") as fh: diff --git a/tests/smoke_test/run.sh b/tests/smoke_test/run.sh index 4389bf4..18e959f 100755 --- a/tests/smoke_test/run.sh +++ b/tests/smoke_test/run.sh @@ -37,7 +37,15 @@ DSAGT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" PDIR="${HOME}/dsagt-projects/${PROJECT}" case "${AGENT}" in - goose|claude|cline|codex|opencode) ;; + goose|claude|codex|opencode) ;; + cline) + # dsagt start --script hard-errors for cline (its anthropic provider + # rewrites unrecognized model names, so batch mode is unsupported — + # see agents/cline.py). Skip rather than report 15 red checks; drop + # this arm when that guard is lifted. + echo "[smoke] SKIP: cline batch mode is unsupported (see agents/cline.py) — hand-test via tests/manual_walkthroughs/ instead" + exit 0 + ;; *) echo "ERROR: agent must be one of: goose, claude, cline, codex, opencode (got '${AGENT}')" >&2 exit 2 @@ -168,8 +176,11 @@ check() { check "greet spec written" "test -f '${PDIR}/codes/greet.md'" # The execution went through dsagt-run iff the record captured greet's # actual stdout — an agent that ran the script by hand can't fake the -# trace_archive record. -check "greet executed via dsagt-run" "grep -l 'Ahoy, DSAGT' '${PDIR}/trace_archive/'*greet*.json" +# trace_archive record. Match only the greeting prefix: it proves our +# custom --greeting arg flowed through the registered code, while +# tolerating an agent flubbing which word goes in the name slot (goose +# produced "Ahoy, Ahoy!"). +check "greet executed via dsagt-run" "grep -l 'Ahoy,' '${PDIR}/trace_archive/'*greet*.json" check "greet re-run in session 2" "test \$(ls '${PDIR}/trace_archive/'*greet*.json | wc -l) -ge 2" check "scan_directory record" "ls '${PDIR}/trace_archive/'*scan_directory*.json" @@ -235,20 +246,17 @@ df = mlflow.search_traces( locations=[exp.experiment_id], max_results=500, ) -n = 0 -for _, row in df.iterrows(): - spans = row.get("spans") or [] - # Match by service.name on root span — agent-emitted traces only. - # MCP-server traces (kb.*, registry.*, code.execute) carry - # service.name = "dsagt-server" / "dsagt-run" and shouldn't count - # toward agent turn parity. - for s in spans: - attrs = getattr(s, "attributes", None) or ( - s.get("attributes") if isinstance(s, dict) else None - ) - if attrs and not str(attrs.get("service.name", "")).startswith("dsagt-"): - n += 1 - break +# MLflowSink stamps every replayed agent trace with "dsagt.trace_id" in +# its trace metadata; DSAGT's internal MCP/dsagt-run debug traces carry a +# "dsagt.source" tag instead — the positive marker is the reliable +# filter. A service.name span heuristic previously counted internal +# spans lacking that attribute as agent traces, masking a codex reader +# that collected nothing. +n = sum( + 1 + for _, row in df.iterrows() + if "dsagt.trace_id" in (row.get("trace_metadata") or {}) +) print(n) PY ) diff --git a/tests/smoke_test/script.txt b/tests/smoke_test/script.txt index 906151f..0d37dbf 100644 --- a/tests/smoke_test/script.txt +++ b/tests/smoke_test/script.txt @@ -2,7 +2,7 @@ Ingest the docs in {{SMOKE_DIR}}/knowledge/ into a collection named knowledge. I have a small CLI utility at {{SMOKE_DIR}}/greet.py — its reference doc is in the knowledge collection you just ingested. Register it in the code registry as "greet" so we can reuse it later. -Run the registered greet code to greet "DSAGT" with the greeting "Ahoy". +Run the registered greet code with the name argument "DSAGT" and the greeting argument "Ahoy". Use the exact shell command from its registered spec (the dsagt-run wrapper) so the execution is recorded in the trace archive. Use the scan_directory code from the registry to scan the {{SMOKE_DIR}}/data/ directory so I can see what's in it. From 5162c8d8868b0b2f7cdf516acddfeab4cd1d6f53 Mon Sep 17 00:00:00 2001 From: aarontuor Date: Thu, 2 Jul 2026 13:35:26 -0700 Subject: [PATCH 25/44] docs(registry): record why provenance is baked into the shell command Agents routinely sidestepped MCP-dispatched execution with their own bash tools; dsagt-run inside the stored command makes that path harmless. Capture the rationale where the wrapping code lives. Co-Authored-By: Claude Fable 5 --- src/dsagt/registry.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/dsagt/registry.py b/src/dsagt/registry.py index f0bfb84..22acab0 100644 --- a/src/dsagt/registry.py +++ b/src/dsagt/registry.py @@ -7,6 +7,13 @@ name, description, executable, parameters, dependencies, tags. Stored in `/codes/`. Agent-written scripts go in `/codes/scripts/`. When registered, executables are wrapped with dsagt-run + uv run --with. +The wrapper lives *inside* the stored shell command by design: execution +used to be dispatched by MCP-server tools, but agents routinely sidestepped +those with their own bash tools, losing provenance. Baking dsagt-run into +the command the agent copies makes the bash path harmless — the residual +failure mode is an agent reconstructing the command from memory and +dropping the wrapper, which is why specs render the exact runnable command +and agent instructions say to copy it verbatim. **Skills** (agent instructions) — directories containing a SKILL.md with YAML frontmatter (name, description, tags) and optional reference docs. From 3021a91dac8cd3e5034d293a51beb1a39fff66a0 Mon Sep 17 00:00:00 2001 From: aarontuor Date: Thu, 2 Jul 2026 14:18:17 -0700 Subject: [PATCH 26/44] feat(registry): codes are skill-standard dirs, mirrored into agent native skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registered codes move from flat codes/.md to skill-standard directories (codes//SKILL.md + per-code scripts/), making each code a self-contained, portable dir structurally identical to an installed skill. Because codes now share the skill envelope, setup_skills mirrors them into the agent's native skills dir unchanged — native discovery puts the exact dsagt-run command in context at invocation time, a second discovery path (alongside search_registry) aimed at the from-memory command-reconstruction failure mode observed with opencode. The agent-facing MCP surface is unchanged. - Code names constrained to the skill charset (lowercase-hyphen); save_code_spec validates with an actionable error. Bundled code renamed scan_directory → scan-directory (python module unchanged). - SKILL.md body leads with the exact wrapped command + the copy-byte-for-byte instruction — what native discovery injects. - Mirror order: codes first, skills last — a deliberately installed instruction skill wins a name collision with a code. - dsagt start again refreshes the dynamic agent record (MCP config + native mirror, all idempotent) — the refresh had been dropped from _cmd_start, silently breaking install_skill's 'available after the next dsagt start' promise; restored, so mid-session-registered codes and installed skills mirror at the next start. - opencode gains its missing native_skills_dir (.agents/skills, the AGENTS.md-convention dir codex/goose already use) — it was the one agent with no native mirror at all. - Smoke test asserts both native mirrors (bundled code at session 1's start, mid-session-registered greet at session 2's). Verified end-to-end: smoke sweep PASS 4/4 (claude, codex, goose, opencode; cline SKIPs by design). Unit suite: 624 passed. Known issue (pre-existing, surfaced by a parallel sweep): concurrent dsagt init runs race on projects.yaml registration — smoke-test --all can clobber project registrations; needs a file lock. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 4 +- README.md | 7 +- docs/architecture.md | 3 +- docs/provenance.md | 2 +- docs/quickstart.md | 2 +- pyproject.toml | 2 +- src/dsagt/agents/base.py | 32 ++++--- src/dsagt/agents/opencode.py | 2 + .../SKILL.md} | 6 +- src/dsagt/commands/cli.py | 31 ++++--- src/dsagt/commands/setup_core_kb.py | 2 +- src/dsagt/mcp/registry_tools.py | 19 ++++- src/dsagt/registry.py | 74 ++++++++++++---- src/dsagt/session.py | 9 +- tests/manual_walkthroughs/cli_walkthrough.md | 4 +- .../manual_walkthroughs/vscode_walkthrough.md | 2 +- tests/smoke_test/run.sh | 9 +- tests/smoke_test/script.txt | 2 +- tests/test_registry.py | 18 ++-- tests/test_registry_server.py | 85 ++++++++++--------- tests/test_skills_catalog.py | 40 +++++++++ 21 files changed, 236 insertions(+), 119 deletions(-) rename src/dsagt/codes/{scan_directory.md => scan-directory/SKILL.md} (93%) diff --git a/CLAUDE.md b/CLAUDE.md index f998d70..6d9efee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,7 +58,7 @@ The codebase separates **commands** (entry points with argparse, launched as CLI Entry points (`pyproject.toml` `[project.scripts]`): `dsagt` → `dsagt.commands.cli:main`, `dsagt-run` → `dsagt.commands.run_code:main`, `dsagt-server` → `dsagt.mcp.server:main`. **Bundled assets** (shipped as `package-data`): -- `src/dsagt/codes/` — built-in code specs (markdown + YAML frontmatter) copied into new projects. +- `src/dsagt/codes/` — built-in codes as skill-standard dirs (`/SKILL.md`), served from the package (never copied into projects). - `src/dsagt/skills/` — built-in skills (e.g., `skill-creator`) the agent discovers via `search_skills`. - `src/dsagt/dsagt_instructions.md` — agent-agnostic system instructions injected into per-agent files at init. @@ -122,7 +122,7 @@ A single merged `dsagt-server` (`src/dsagt/mcp/`) exposes 20 tools across four c - **Agent-agnostic**: DSAGT is infrastructure, not an agent. Capabilities are MCP services. - **Session isolation**: each project gets its own directory with config, tools, skills, kb_index, trace_archive, and the `mlflow.db` sqlite store. -- **Codes vs Skills**: Codes are CLI executables in `/codes/` (specs with parameters, wrapped by dsagt-run). Skills are agent instruction workflows in `/skills/` (SKILL.md + reference docs). Both are discoverable via ChromaDB-backed semantic search. +- **Codes vs Skills**: Codes are CLI executables in `/codes//` (skill-standard dirs whose SKILL.md frontmatter adds executable/parameters; wrapped by dsagt-run). Skills are agent instruction workflows in `/skills/` (SKILL.md + reference docs). Both share the skill envelope, so both mirror into the agent's native skills dir; both are also discoverable via ChromaDB-backed semantic search (`search_registry` / `search_skills`). ## DSAGT Pipeline Builder Workflow diff --git a/README.md b/README.md index 6c6728d..ee2a437 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ Inside the agent, paste these prompts one at a time (substitute the absolute pat 1. > Ingest the docs in `$SMOKE_DIR/knowledge/` into a collection named `knowledge`. 2. > Register the csvkit CLI codes `csvcut`, `csvgrep`, `csvstat`, and `csvlook`. -3. > Use the `scan_directory` code from the registry to scan `$SMOKE_DIR/data/`. +3. > Use the `scan-directory` code from the registry to scan `$SMOKE_DIR/data/`. 4. > Summarize `samples.csv` — columns, row count, quality issues using csvkit codes from the registry. 5. > Put this in explicit memory: samples.csv has null values in the status and timestamp columns. 6. > Tell me what you remember about the samples dataset. @@ -93,7 +93,7 @@ This exercised: | Prompt | Capability | |---|---| | 1 | `dsagt-server` (`kb_ingest`) — chunks and indexes docs into ChromaDB | -| 2 | `dsagt-server` (`save_code_spec`) — writes `codes/csvcut.md`, `codes/csvgrep.md`, etc. (one per registered code) | +| 2 | `dsagt-server` (`save_code_spec`) — writes `codes/csvcut/SKILL.md`, `codes/csvgrep/SKILL.md`, etc. (one skill-standard dir per registered code) | | 3 | `dsagt-run` provenance wrapper — records the execution to `trace_archive/` | | 5–6 | Explicit memory (`kb_remember` → `.dsagt/explicit_memories.yaml`) + KB recall (`kb_get_memories`) | @@ -144,8 +144,7 @@ Projects are registered in `~/dsagt-projects/projects.yaml` so `dsagt info / # registered codes — skill-standard dirs (SKILL.md + scripts/) skills/ # agent skills (SKILL.md + reference docs) trace_archive/ # code execution records (JSON, from dsagt-run) mlflow.db # serverless MLflow SQLite trace store diff --git a/docs/architecture.md b/docs/architecture.md index 65b72f7..f7adcaf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -32,8 +32,7 @@ DSAgt adds two memory extensions that complement the host agent's own memory (wh config.yaml # project configuration (set by dsagt init) state.yaml # session log + memory cursor (owned by the MCP server) explicit_memories.yaml # user-confirmed facts - codes/ # registered CLI code specs (markdown + YAML frontmatter) - codes/scripts/ # agent-written codes + codes// # registered codes — skill-standard dirs (SKILL.md + scripts/) skills/ # agent skills (SKILL.md + reference docs) trace_archive/ # code execution records (JSON, from dsagt-run) mlflow.db # serverless MLflow SQLite trace store diff --git a/docs/provenance.md b/docs/provenance.md index 6f56d7f..c55cef9 100644 --- a/docs/provenance.md +++ b/docs/provenance.md @@ -26,7 +26,7 @@ Prints descriptive statistics for all columns in a CSV file. Usage: csvstat [options] [FILE] ``` -DSAgt wraps every registered code with `dsagt-run` for provenance capture and `uv run --with` for Python dependencies, so the agent can call any code without managing environments manually. It ships one bundled code, `scan_directory`, indexed for search by `dsagt init`. +DSAgt wraps every registered code with `dsagt-run` for provenance capture and `uv run --with` for Python dependencies, so the agent can call any code without managing environments manually. It ships one bundled code, `scan-directory`, indexed for search by `dsagt init`. ## Execution capture diff --git a/docs/quickstart.md b/docs/quickstart.md index ed034fd..39af7f0 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -28,7 +28,7 @@ Inside the agent, paste these prompts one at a time. Replace `$SMOKE_DIR` with t 1. > Ingest the docs in `$SMOKE_DIR/knowledge/` into a collection named `knowledge`. 2. > Register the csvkit CLI codes `csvcut`, `csvgrep`, `csvstat`, and `csvlook`. -3. > Use the `scan_directory` code from the registry to scan `$SMOKE_DIR/data/`. +3. > Use the `scan-directory` code from the registry to scan `$SMOKE_DIR/data/`. 4. > Summarize `samples.csv` — columns, row count, quality issues using csvkit codes from the registry. 5. > Put this in explicit memory: samples.csv has null values in the status and timestamp columns. 6. > Tell me what you remember about the samples dataset. diff --git a/pyproject.toml b/pyproject.toml index 8dc297d..a2fb2f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,7 +62,7 @@ version = {attr = "dsagt.__version__"} where = ["src"] [tool.setuptools.package-data] -dsagt = ["py.typed", "codes/*.md", "codes/*.py", "skills/**/*", "dsagt_instructions.md"] +dsagt = ["py.typed", "codes/**/*.md", "codes/*.py", "skills/**/*", "dsagt_instructions.md"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/src/dsagt/agents/base.py b/src/dsagt/agents/base.py index cad375d..43c411a 100644 --- a/src/dsagt/agents/base.py +++ b/src/dsagt/agents/base.py @@ -306,11 +306,12 @@ class AgentSetup(ABC): #: Directory (relative to the working dir) the agent natively auto-discovers #: ``SKILL.md`` skill folders from. ``setup_skills`` mirrors installed - #: (bundled + project) skills here so the agent discovers/auto-invokes them - #: without an MCP round-trip. Every supported agent has one — claude - #: ``.claude/skills``, codex/goose ``.agents/skills`` (the cross-agent - #: standard), cline ``.cline/skills``. ``None`` would - #: mean the agent has no native skill discovery (none currently). + #: (bundled + project) skills AND registered codes here so the agent + #: discovers/auto-invokes them without an MCP round-trip. Every supported + #: agent has one — claude ``.claude/skills``, codex/goose/opencode + #: ``.agents/skills`` (the cross-agent standard), cline ``.cline/skills``. + #: ``None`` would mean the agent has no native skill discovery + #: (none currently). native_skills_dir: ClassVar[str | None] = None @abstractmethod @@ -337,8 +338,14 @@ def write_dynamic( """ def setup_skills(self, working_dir: Path, config: dict) -> list[str]: - """Mirror installed (bundled + project) skills into the agent's native - skills dir so it auto-discovers/auto-invokes them. + """Mirror installed skills AND registered codes into the agent's + native skills dir so it auto-discovers/auto-invokes them. + + Codes share the skill-standard envelope (``codes//SKILL.md``), + so the same copy serves both: native discovery puts a code's exact + dsagt-run command in context at invocation time — a second discovery + path alongside ``search_registry``, aimed at the from-memory + command-reconstruction failure mode. Idempotent — the manifest-tracked :func:`_mirror_skills_to` only reaps skills dsagt placed, never user-authored ones. No-op when the @@ -349,11 +356,16 @@ def setup_skills(self, working_dir: Path, config: dict) -> list[str]: return [] if not (config.get("skills") or {}).get("populate_native", True): return [] - from dsagt.registry import SkillRegistry + from dsagt.registry import CodeRegistry, SkillRegistry + codes = CodeRegistry(runtime_dir=working_dir, kb=None) reg = SkillRegistry(runtime_dir=working_dir, kb=None) - # Bundled first, project last → project wins name collisions. - src_dirs = reg._bundled_skill_dirs() + reg._project_skill_dirs() + # Later entries win name collisions: codes first, then bundled + # skills, then project skills — a deliberately installed instruction + # skill outranks a registered code of the same name. + src_dirs = ( + codes.code_dirs() + reg._bundled_skill_dirs() + reg._project_skill_dirs() + ) target = working_dir for part in self.native_skills_dir.split("/"): target = target / part diff --git a/src/dsagt/agents/opencode.py b/src/dsagt/agents/opencode.py index 002dd7e..b010be0 100644 --- a/src/dsagt/agents/opencode.py +++ b/src/dsagt/agents/opencode.py @@ -116,6 +116,8 @@ class OpenCodeSetup(AgentSetup): base_command = ["opencode"] static_marker = "AGENTS.md" install_hint = "Install with `npm i -g opencode-ai`." + # The AGENTS.md-convention skills dir codex/goose also use. + native_skills_dir = ".agents/skills" # OpenCode reads provider creds via ``{env:VAR}`` interpolation in # its config — the file references these vars, opencode resolves # them from the user's shell at runtime. Same shape as goose's diff --git a/src/dsagt/codes/scan_directory.md b/src/dsagt/codes/scan-directory/SKILL.md similarity index 93% rename from src/dsagt/codes/scan_directory.md rename to src/dsagt/codes/scan-directory/SKILL.md index bd93ac8..8a78fd4 100644 --- a/src/dsagt/codes/scan_directory.md +++ b/src/dsagt/codes/scan-directory/SKILL.md @@ -1,8 +1,8 @@ --- -name: scan_directory +name: scan-directory description: Scan a data directory and produce structured report with file counts, sizes, and directory tree -executable: dsagt-run --code scan_directory -- python -m dsagt.codes.scan_directory +executable: dsagt-run --code scan-directory -- python -m dsagt.codes.scan_directory parameters: directory: type: string @@ -23,7 +23,7 @@ parameters: description: Number of largest files to list --- -# scan_directory +# scan-directory Scan a data directory and produce a structured report with file counts, sizes, and directory tree. Use this as your first step when exploring a new dataset to understand its layout before deciding how to process it. diff --git a/src/dsagt/commands/cli.py b/src/dsagt/commands/cli.py index ece9bbc..b79d7b9 100644 --- a/src/dsagt/commands/cli.py +++ b/src/dsagt/commands/cli.py @@ -3,10 +3,12 @@ ``dsagt init`` is the single, interactive, re-runnable place a user expresses every choice; the prompts mirror ``.dsagt/config.yaml`` 1:1 and it writes the -per-agent instructions + MCP config. ``dsagt start `` is pure -convenience — ``cd && `` and nothing else (the MCP server -owns the session lifecycle, minting session ids into ``.dsagt/state.yaml`` and -catching up post-session extraction in the background at startup). +per-agent instructions + MCP config. ``dsagt start `` refreshes the +dynamic agent record (MCP config + native-skills mirror, idempotent) and then +launches the agent — ``cd && `` plus the refresh (the MCP +server owns the session lifecycle, minting session ids into +``.dsagt/state.yaml`` and catching up post-session extraction in the +background at startup). The agent talks to its provider directly — DSAGT never interposes on its traffic. Self-logging goes to a serverless ``sqlite:////mlflow.db`` @@ -402,17 +404,19 @@ def _cmd_init(args): def _cmd_start(args): - """Pure convenience: ``cd && ``. + """Refresh the dynamic agent record, then ``cd && ``. - Nothing more — the agent is launched in the foreground at the project - dir. All configuration is owned by ``dsagt init``; the session - lifecycle (session-id minting, post-session extraction catch-up) is - owned by the MCP server at startup. ``dsagt start`` has no behavior the - user couldn't get by hand with ``cd && ``. + The refresh (``dynamic_agent_record``: per-agent MCP config + + native-skills mirror, all idempotent) is what makes ``install_skill`` / + ``save_code_spec`` results appear natively "after the next dsagt start", + and lets a credential-dependent step skipped at init (cline auth) pick + up once the vars are in the shell. Session lifecycle (session-id + minting, post-session extraction catch-up) is owned by the MCP server + at startup. The per-project runtime env (e.g. ``CLINE_DIR`` / ``CODEX_HOME`` that - point an agent at its init-written config) is still applied so the - launched agent finds what ``init`` set up. + point an agent at its init-written config) is applied so the launched + agent finds what ``init`` set up. """ config = load_config(args.project) pdir = Path(config["project_dir"]) @@ -422,6 +426,9 @@ def _cmd_start(args): print(f" Dir: {pdir}") print() + for action in dynamic_agent_record(config, env=dict(os.environ), working_dir=pdir): + print(f" {action}") + env = agent_env(config) return launch_agent( config, diff --git a/src/dsagt/commands/setup_core_kb.py b/src/dsagt/commands/setup_core_kb.py index e223566..8c0858e 100644 --- a/src/dsagt/commands/setup_core_kb.py +++ b/src/dsagt/commands/setup_core_kb.py @@ -395,7 +395,7 @@ def _build_bundled_tools(kb, index_dir: Path) -> int: code_paths = [ p - for p in sorted(CodeRegistry._PACKAGE_CODES_DIR.glob("*.md")) + for p in sorted(CodeRegistry._PACKAGE_CODES_DIR.glob("*/SKILL.md")) if _parse_frontmatter(p).get("name") ] if not code_paths: diff --git a/src/dsagt/mcp/registry_tools.py b/src/dsagt/mcp/registry_tools.py index df58b54..790e889 100644 --- a/src/dsagt/mcp/registry_tools.py +++ b/src/dsagt/mcp/registry_tools.py @@ -389,7 +389,11 @@ def _registry_tools_and_handlers( ), types.Tool( name="save_code_spec", - description="Save a tool specification to the registry as a skill file", + description=( + "Register a CLI code: writes a skill-standard spec dir " + "(codes//SKILL.md) the agent also auto-discovers " + "natively after the next dsagt start" + ), inputSchema={ "type": "object", "properties": { @@ -406,11 +410,20 @@ def _registry_tools_and_handlers( "properties": { "name": { "type": "string", - "description": "Unique tool identifier", + "description": ( + "Unique code name — lowercase " + "letters, digits, hyphens (e.g. " + "'scan-directory')" + ), }, "description": { "type": "string", - "description": "What the tool does", + "description": ( + "What the code does and when to " + "use it — phrase as 'Use when " + "…' so native skill routing can " + "match it" + ), }, "executable": { "type": "string", diff --git a/src/dsagt/registry.py b/src/dsagt/registry.py index 22acab0..42541d4 100644 --- a/src/dsagt/registry.py +++ b/src/dsagt/registry.py @@ -3,9 +3,16 @@ Two parallel registries for agent capabilities: -**Codes** (CLI executables) — markdown files with YAML frontmatter specifying -name, description, executable, parameters, dependencies, tags. Stored in -`/codes/`. Agent-written scripts go in `/codes/scripts/`. +**Codes** (CLI executables) — skill-standard directories +(`/codes//SKILL.md`) whose frontmatter carries the machine +fields (name, description, executable, parameters, dependencies, tags) on +top of the skill-required name/description. Agent-written scripts live +beside their spec in `/codes//scripts/`, making each +registered code a self-contained, portable directory. The skill-standard +envelope means codes mirror into the agent's native skills dir unchanged +(see ``AgentSetup.setup_skills``) — native discovery puts the exact +runnable command in context at invocation time, alongside MCP discovery +via ``search_registry``. When registered, executables are wrapped with dsagt-run + uv run --with. The wrapper lives *inside* the stored shell command by design: execution used to be dispatched by MCP-server tools, but agents routinely sidestepped @@ -27,6 +34,7 @@ from __future__ import annotations import logging +import re from pathlib import Path from typing import TYPE_CHECKING @@ -84,6 +92,11 @@ def _uv_run_prefix(deps: list[str]) -> str: return f"uv run --with {','.join(deps)} -- " +#: Skill-standard name charset — agent native skill loaders (claude et al.) +#: require lowercase-hyphen names, and codes mirror into those dirs. +_CODE_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") + + def _wrap_executable(name: str, executable: str, deps: list[str] | None = None) -> str: """Wrap an executable with uv run (for Python deps) and dsagt-run (for provenance). @@ -96,12 +109,21 @@ def _wrap_executable(name: str, executable: str, deps: list[str] | None = None) def _generate_code_body(spec: dict) -> str: - """Generate a markdown body for a new code file from its spec.""" + """Generate a markdown body for a new code file from its spec. + + The exact runnable command leads the body: native skill discovery + injects SKILL.md at invocation time, and the thing the agent must copy + verbatim (the dsagt-run-wrapped command) belongs at the top, not after + prose it may stop reading. + """ lines = [ - f"\n# {spec['name']}\n\n{spec['description']}\n\n", - "## Shell Command\n\n```bash\n", + f"\n# {spec['name']}\n\n", + "Run this registered code with the exact shell command below — copy " + "it byte-for-byte (the `dsagt-run` prefix writes the execution " + "record to `trace_archive/`):\n\n```bash\n", f"{spec['executable']} [options]\n", - "```\n\n## Parameters\n\n", + "```\n\n", + f"{spec['description']}\n\n## Parameters\n\n", ] params = spec.get("parameters", {}) if params: @@ -328,19 +350,27 @@ def __init__( # Project code dir is always agent-writable. We no longer # pre-populate it with bundled codes — they're served directly # from the package via the merge in list_codes / get_code. + # Each code is a self-contained skill-standard directory + # (``codes//SKILL.md`` + optional ``scripts/``), so there is + # no shared scripts/ dir to pre-create. self.codes_dir.mkdir(parents=True, exist_ok=True) - # Ensure scripts/ subdirectory exists for agent-written scripts. - (self.codes_dir / "scripts").mkdir(exist_ok=True) def _bundled_code_paths(self) -> list[Path]: - """Return .md code spec paths shipped with the package.""" + """Return SKILL.md spec paths shipped with the package.""" if not self._bundled_dir.exists(): return [] - return sorted(self._bundled_dir.glob("*.md")) + return sorted(self._bundled_dir.glob("*/SKILL.md")) def _project_code_paths(self) -> list[Path]: - """Return .md code spec paths the agent has saved into this project.""" - return sorted(self.codes_dir.glob("*.md")) + """Return SKILL.md spec paths the agent has saved into this project.""" + return sorted(self.codes_dir.glob("*/SKILL.md")) + + def code_dirs(self) -> list[Path]: + """All code directories, bundled first so project wins downstream + name collisions (mirror order — see ``AgentSetup.setup_skills``).""" + return [p.parent for p in self._bundled_code_paths()] + [ + p.parent for p in self._project_code_paths() + ] def list_codes_raw(self) -> list[dict]: """Return full frontmatter dicts for all codes. @@ -394,12 +424,12 @@ def list_codes(self) -> list[dict]: def get_code(self, name: str) -> dict | None: """Look up a code spec by name. Project layer overrides bundled.""" - project_path = self.codes_dir / f"{name}.md" + project_path = self.codes_dir / name / "SKILL.md" if project_path.exists(): code = _parse_frontmatter(project_path) if code.get("name") == name: return code - bundled_path = self._bundled_dir / f"{name}.md" + bundled_path = self._bundled_dir / name / "SKILL.md" if bundled_path.exists(): code = _parse_frontmatter(bundled_path) if code.get("name") == name: @@ -407,7 +437,7 @@ def get_code(self, name: str) -> dict | None: return None def save_tool(self, spec: dict) -> str: - """Write or update a code file. Returns 'added' or 'updated'. + """Write or update a code's SKILL.md. Returns 'added' or 'updated'. Automatically wraps the executable: - With `uv run --with ` if Python dependencies are specified @@ -415,8 +445,18 @@ def save_tool(self, spec: dict) -> str: If a KnowledgeBase is available, indexes the code for semantic search. """ - path = self.codes_dir / f"{spec['name']}.md" + # Codes share the skill-standard envelope so they mirror into agent + # native skills dirs, whose loaders require lowercase-hyphen names. + if not _CODE_NAME_RE.match(spec["name"]): + raise ValueError( + f"invalid code name {spec['name']!r}: use lowercase letters, " + "digits, and hyphens (the skill-standard charset agent native " + "skill loaders require), e.g. 'scan-directory'" + ) + code_dir = self.codes_dir / spec["name"] + path = code_dir / "SKILL.md" action = "updated" if path.exists() else "added" + code_dir.mkdir(parents=True, exist_ok=True) spec = dict(spec) spec["executable"] = _wrap_executable( diff --git a/src/dsagt/session.py b/src/dsagt/session.py index ce141ac..19bb871 100644 --- a/src/dsagt/session.py +++ b/src/dsagt/session.py @@ -14,8 +14,7 @@ explicit_memories.yaml, ... # explicit memory trace_archive/ # tool execution records mlflow.db # serverless MLflow SQLite trace store (created lazily) - tools/ # registered CLI tools - codes/scripts/ # agent-written tool scripts + codes// # registered codes (skill-standard dirs: SKILL.md + scripts/) skills/ # instruction-based agent skills kb_index/ # knowledge base collections """ @@ -621,10 +620,8 @@ def init_project( pdir = (location or DEFAULT_PROJECTS_BASE) / project_name pdir.mkdir(parents=True, exist_ok=True) - # `tools/` and `codes/scripts/` are created by CodeRegistry on first server - # startup so bundled tools get copied in (it short-circuits if tools/ - # already exists). ``mlflow.db`` is created lazily by the MLflow client - # on first span. + # `codes/` is created by CodeRegistry on first server startup. + # ``mlflow.db`` is created lazily by the MLflow client on first span. for subdir in ("trace_archive", "skills", CONFIG_DIRNAME): (pdir / subdir).mkdir(parents=True, exist_ok=True) diff --git a/tests/manual_walkthroughs/cli_walkthrough.md b/tests/manual_walkthroughs/cli_walkthrough.md index 54df938..18923f9 100644 --- a/tests/manual_walkthroughs/cli_walkthrough.md +++ b/tests/manual_walkthroughs/cli_walkthrough.md @@ -46,9 +46,9 @@ cd ~/dsagt-projects/smoke- && **Expect:** `save_code_spec` MCP call; `~/dsagt-projects/smoke-/codes/csvtool_filter.md` exists. -3. > Use the `scan_directory` tool from the registry to scan `$SMOKE_DIR/data/`. +3. > Use the `scan-directory` tool from the registry to scan `$SMOKE_DIR/data/`. - **Expect:** Agent invokes `dsagt-run --code scan_directory ...`; one record in `~/dsagt-projects/smoke-/trace_archive/scan_directory_*.json`. + **Expect:** Agent invokes `dsagt-run --code scan-directory ...`; one record in `~/dsagt-projects/smoke-/trace_archive/scan-directory_*.json`. 4. > Look at `$SMOKE_DIR/data/samples.csv` and summarize — columns, row count, quality issues. diff --git a/tests/manual_walkthroughs/vscode_walkthrough.md b/tests/manual_walkthroughs/vscode_walkthrough.md index 50a0234..c81accc 100644 --- a/tests/manual_walkthroughs/vscode_walkthrough.md +++ b/tests/manual_walkthroughs/vscode_walkthrough.md @@ -40,7 +40,7 @@ Inside VS Code: 1. > Ingest the docs in `$SMOKE_DIR/knowledge/` into a collection named `knowledge`. 2. > I have a CSV utility called `csvtool`. Its reference is at `$SMOKE_DIR/knowledge/api_reference.md` — register the `filter` subcommand. Use an underscore in the name. -3. > Use the `scan_directory` tool from the registry to scan `$SMOKE_DIR/data/`. +3. > Use the `scan-directory` tool from the registry to scan `$SMOKE_DIR/data/`. 4. > Look at `$SMOKE_DIR/data/samples.csv` and summarize — columns, row count, quality issues. 5. > Put this in explicit memory: samples.csv has null values in the status and timestamp columns. 6. > What do you remember about the samples dataset? diff --git a/tests/smoke_test/run.sh b/tests/smoke_test/run.sh index 18e959f..4e10864 100755 --- a/tests/smoke_test/run.sh +++ b/tests/smoke_test/run.sh @@ -173,7 +173,12 @@ check() { } # -- registry + execution + provenance -------------------------------------- -check "greet spec written" "test -f '${PDIR}/codes/greet.md'" +check "greet spec written" "test -f '${PDIR}/codes/greet/SKILL.md'" +# Codes share the skill-standard envelope and mirror into the agent's +# native skills dir at dsagt start: the bundled scan-directory at +# session 1's start, greet (registered mid-session-1) at session 2's. +check "bundled code mirrored natively" "find '${PDIR}' -path '*skills/scan-directory/SKILL.md' | grep -q ." +check "greet mirrored natively" "find '${PDIR}' -path '*skills/greet/SKILL.md' | grep -q ." # The execution went through dsagt-run iff the record captured greet's # actual stdout — an agent that ran the script by hand can't fake the # trace_archive record. Match only the greeting prefix: it proves our @@ -182,7 +187,7 @@ check "greet spec written" "test -f '${PDIR}/codes/greet.md'" # produced "Ahoy, Ahoy!"). check "greet executed via dsagt-run" "grep -l 'Ahoy,' '${PDIR}/trace_archive/'*greet*.json" check "greet re-run in session 2" "test \$(ls '${PDIR}/trace_archive/'*greet*.json | wc -l) -ge 2" -check "scan_directory record" "ls '${PDIR}/trace_archive/'*scan_directory*.json" +check "scan-directory record" "ls '${PDIR}/trace_archive/'*scan-directory*.json" # -- knowledge base ---------------------------------------------------------- # Both files are written by dsagt-server's kb_ingest MCP tool — chroma.sqlite3 diff --git a/tests/smoke_test/script.txt b/tests/smoke_test/script.txt index 0d37dbf..85cacfc 100644 --- a/tests/smoke_test/script.txt +++ b/tests/smoke_test/script.txt @@ -4,7 +4,7 @@ I have a small CLI utility at {{SMOKE_DIR}}/greet.py — its reference doc is in Run the registered greet code with the name argument "DSAGT" and the greeting argument "Ahoy". Use the exact shell command from its registered spec (the dsagt-run wrapper) so the execution is recorded in the trace archive. -Use the scan_directory code from the registry to scan the {{SMOKE_DIR}}/data/ directory so I can see what's in it. +Use the scan-directory code from the registry to scan the {{SMOKE_DIR}}/data/ directory so I can see what's in it. Search the knowledge collection: what error code does greet report when the name argument is empty? Reply with the code verbatim. diff --git a/tests/test_registry.py b/tests/test_registry.py index 4d27eb4..4981614 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -92,10 +92,11 @@ def test_valid_yaml_still_uses_strict_path(self, tmp_path): def _write_tool(codes_dir, spec: dict) -> None: - """Write a minimal skill file for the given spec dict.""" - path = codes_dir / f"{spec['name']}.md" + """Write a minimal skill-standard code dir for the given spec dict.""" + code_dir = codes_dir / spec["name"] + code_dir.mkdir(parents=True, exist_ok=True) frontmatter = yaml.dump(spec, default_flow_style=False, sort_keys=False) - path.write_text(f"---\n{frontmatter}---\n\n# {spec['name']}\n") + (code_dir / "SKILL.md").write_text(f"---\n{frontmatter}---\n\n# {spec['name']}\n") def make_registry(tmp_path, tools: list[dict]) -> CodeRegistry: @@ -284,7 +285,8 @@ def test_update_returns_updated(self, empty_registry): def test_update_preserves_body(self, empty_registry): """Updating a tool preserves any hand-edited markdown body.""" - skill_path = empty_registry.codes_dir / "ping.md" + skill_path = empty_registry.codes_dir / "ping" / "SKILL.md" + skill_path.parent.mkdir(parents=True) spec = TOOL_NO_PARAMS fm = __import__("yaml").dump(spec, default_flow_style=False, sort_keys=False) skill_path.write_text(f"---\n{fm}---\n\n# Custom docs written by hand.\n") @@ -328,9 +330,9 @@ def test_source_unchanged_after_init(self, tmp_path): reg.save_tool(TOOL_WITH_MIXED_PARAMS) # Source should be unchanged - source_files = list(source_dir.glob("*.md")) + source_files = list(source_dir.glob("*/SKILL.md")) assert len(source_files) == 1 - assert source_files[0].stem == "ping" + assert source_files[0].parent.name == "ping" # Runtime should have both tools assert len(reg.list_codes()) == 2 @@ -351,7 +353,7 @@ def test_tools_directory_exists(self): def test_tools_are_valid(self): """Every tool file must parse cleanly and have required fields.""" - tool_files = list(CodeRegistry._PACKAGE_CODES_DIR.glob("*.md")) + tool_files = list(CodeRegistry._PACKAGE_CODES_DIR.glob("*/SKILL.md")) assert len(tool_files) > 0, "No tool files found in package" for path in tool_files: @@ -367,7 +369,7 @@ def test_default_init_fallback(self, tmp_path): tools = reg.list_codes() assert len(tools) > 0 names = [t["name"] for t in tools] - assert "scan_directory" in names + assert "scan-directory" in names # --------------------------------------------------------------------------- diff --git a/tests/test_registry_server.py b/tests/test_registry_server.py index e389cc4..f5a0799 100644 --- a/tests/test_registry_server.py +++ b/tests/test_registry_server.py @@ -20,7 +20,7 @@ def make_spec( - name="test_tool", + name="test-tool", description="A test tool", executable="echo hello", dependencies=None, @@ -44,9 +44,10 @@ def make_spec( def _write_tool(codes_dir: Path, spec: dict) -> None: - path = codes_dir / f"{spec['name']}.md" + code_dir = codes_dir / spec["name"] + code_dir.mkdir(parents=True, exist_ok=True) fm = yaml.dump(spec, default_flow_style=False, sort_keys=False) - path.write_text(f"---\n{fm}---\n\n# {spec['name']}\n") + (code_dir / "SKILL.md").write_text(f"---\n{fm}---\n\n# {spec['name']}\n") def _make_server(tmp_path, tools=None): @@ -97,8 +98,8 @@ def populated(tmp_path): server, reg = _make_server( tmp_path, tools=[ - make_spec("tool_alpha", "Alpha tool", "python alpha.py"), - make_spec("tool_beta", "Beta data processor", "python beta.py"), + make_spec("tool-alpha", "Alpha tool", "python alpha.py"), + make_spec("tool-beta", "Beta data processor", "python beta.py"), ], ) return server, reg @@ -118,49 +119,49 @@ class TestSaveToolSpec: def test_add_new_tool(self, server, registry): """Saving a new spec creates a skill file and reports added.""" - spec = make_spec("my_tool") + spec = make_spec("my-tool") text = call_tool(server, "save_code_spec", {"spec": spec}) assert "added" in text assert "1 tools" in text - assert registry.get_code("my_tool") is not None + assert registry.get_code("my-tool") is not None def test_update_existing_tool(self, server, registry): """Saving a spec with the same name updates rather than duplicates.""" call_tool( server, "save_code_spec", - {"spec": make_spec("my_tool", description="Version 1")}, + {"spec": make_spec("my-tool", description="Version 1")}, ) text = call_tool( server, "save_code_spec", - {"spec": make_spec("my_tool", description="Version 2")}, + {"spec": make_spec("my-tool", description="Version 2")}, ) assert "updated" in text assert "1 tools" in text - assert registry.get_code("my_tool")["description"] == "Version 2" + assert registry.get_code("my-tool")["description"] == "Version 2" def test_add_multiple_tools(self, server, registry): """Multiple distinct tools accumulate as separate skill files.""" - call_tool(server, "save_code_spec", {"spec": make_spec("tool_a")}) - text = call_tool(server, "save_code_spec", {"spec": make_spec("tool_b")}) + call_tool(server, "save_code_spec", {"spec": make_spec("tool-a")}) + text = call_tool(server, "save_code_spec", {"spec": make_spec("tool-b")}) assert "2 tools" in text - assert registry.get_code("tool_a") is not None - assert registry.get_code("tool_b") is not None + assert registry.get_code("tool-a") is not None + assert registry.get_code("tool-b") is not None def test_accepts_stringified_spec(self, server, registry): """Some MCP clients (Claude Sonnet/Haiku 4.x) send nested-object args as JSON strings. The handler must accept both shapes.""" import json - spec = make_spec("stringy_tool") + spec = make_spec("stringy-tool") text = call_tool(server, "save_code_spec", {"spec": json.dumps(spec)}) assert "added" in text - assert registry.get_code("stringy_tool") is not None + assert registry.get_code("stringy-tool") is not None def test_rejects_invalid_stringified_spec(self, server, registry): """Non-JSON strings produce a clear error rather than crashing.""" @@ -189,8 +190,8 @@ def test_populated_registry(self, populated_server, populated): data = yaml.safe_load(text) assert len(data["codes"]) == 2 names = [t["name"] for t in data["codes"]] - assert "tool_alpha" in names - assert "tool_beta" in names + assert "tool-alpha" in names + assert "tool-beta" in names # --------------------------------------------------------------------------- @@ -212,9 +213,9 @@ class TestSearchRegistryNoKB: def test_exact_name_lookup_works_without_kb(self, populated_server): """code_name lookup is KB-free and must keep working.""" text = call_tool( - populated_server, "search_registry", {"code_name": "tool_alpha"} + populated_server, "search_registry", {"code_name": "tool-alpha"} ) - assert "tool_alpha" in text + assert "tool-alpha" in text def test_exact_name_miss_without_kb(self, populated_server): """code_name with a non-existent name returns a clean 'no tool' message.""" @@ -232,7 +233,7 @@ def test_query_search_without_kb_returns_helpful_error(self, populated_server): ``not in`` assertion pins that the fallback stays gone. """ text = call_tool(populated_server, "search_registry", {"query": "alpha"}) - assert "tool_alpha" not in text # no silent substring fallback + assert "tool-alpha" not in text # no silent substring fallback assert "knowledge base" in text.lower() assert "embedding" in text.lower() @@ -387,7 +388,7 @@ def test_deps_installed_on_save(self, mock_run, server, registry): mock_run.return_value = MagicMock( returncode=0, stdout="Successfully installed pandas-2.1.0", stderr="" ) - spec = make_spec("tool_with_deps", dependencies=["pandas>=2.0", "numpy"]) + spec = make_spec("tool-with-deps", dependencies=["pandas>=2.0", "numpy"]) text = call_tool(server, "save_code_spec", {"spec": spec}) assert "added" in text @@ -410,12 +411,12 @@ def test_deps_failure_still_saves_spec(self, mock_run, server, registry): mock_run.return_value = MagicMock( returncode=1, stdout="", stderr="No matching distribution for bogus-pkg" ) - spec = make_spec("tool_bad_deps", dependencies=["bogus-pkg"]) + spec = make_spec("tool-bad-deps", dependencies=["bogus-pkg"]) text = call_tool(server, "save_code_spec", {"spec": spec}) assert "added" in text assert "Installation failed" in text - tool = registry.get_code("tool_bad_deps") + tool = registry.get_code("tool-bad-deps") assert tool is not None assert tool["dependencies"] == ["bogus-pkg"] @@ -423,7 +424,7 @@ def test_deps_failure_still_saves_spec(self, mock_run, server, registry): def test_deps_timeout(self, mock_run, server): """Timeout during install is reported, spec is still saved.""" mock_run.side_effect = subprocess.TimeoutExpired("uv", 120) - spec = make_spec("tool_slow_deps", dependencies=["heavy-pkg"]) + spec = make_spec("tool-slow-deps", dependencies=["heavy-pkg"]) text = call_tool(server, "save_code_spec", {"spec": spec}) assert "added" in text @@ -431,7 +432,7 @@ def test_deps_timeout(self, mock_run, server): def test_no_deps_no_install_message(self, server, registry): """When no dependencies are provided, no install message appears.""" - spec = make_spec("tool_no_deps") + spec = make_spec("tool-no-deps") text = call_tool(server, "save_code_spec", {"spec": spec}) assert "added" in text @@ -441,17 +442,17 @@ def test_no_deps_no_install_message(self, server, registry): def test_deps_persisted_in_skill_file(self, mock_run, server, registry): """Dependencies are stored in the skill file frontmatter.""" mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="") - spec = make_spec("dep_tool", dependencies=["requests>=2.28"]) + spec = make_spec("dep-tool", dependencies=["requests>=2.28"]) call_tool(server, "save_code_spec", {"spec": spec}) - tool = registry.get_code("dep_tool") + tool = registry.get_code("dep-tool") assert tool["dependencies"] == ["requests>=2.28"] @patch("dsagt.mcp.registry_tools.subprocess.run") def test_uv_not_found(self, mock_run, server): """FileNotFoundError from missing uv is reported gracefully.""" mock_run.side_effect = FileNotFoundError("uv") - spec = make_spec("tool_no_uv", dependencies=["pandas"]) + spec = make_spec("tool-no-uv", dependencies=["pandas"]) text = call_tool(server, "save_code_spec", {"spec": spec}) assert "added" in text @@ -471,16 +472,16 @@ def test_install_all(self, mock_run, tmp_path): server, reg = _make_server( tmp_path, tools=[ - make_spec("tool_a", dependencies=["pandas", "numpy"]), - make_spec("tool_b", dependencies=["numpy", "scipy"]), + make_spec("tool-a", dependencies=["pandas", "numpy"]), + make_spec("tool-b", dependencies=["numpy", "scipy"]), ], ) mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="") text = call_tool(server, "install_dependencies", {}) - assert "tool_a" in text - assert "tool_b" in text + assert "tool-a" in text + assert "tool-b" in text cmd = mock_run.call_args[0][0] assert cmd == [ "uv", @@ -499,18 +500,18 @@ def test_install_single_tool(self, mock_run, tmp_path): server, reg = _make_server( tmp_path, tools=[ - make_spec("tool_a", dependencies=["pandas"]), - make_spec("tool_b", dependencies=["scipy"]), + make_spec("tool-a", dependencies=["pandas"]), + make_spec("tool-b", dependencies=["scipy"]), ], ) mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="") - text = call_tool(server, "install_dependencies", {"code_name": "tool_b"}) + text = call_tool(server, "install_dependencies", {"code_name": "tool-b"}) cmd = mock_run.call_args[0][0] assert cmd == ["uv", "pip", "install", "--python", sys.executable, "scipy"] - assert "tool_b" in text - assert "tool_a" not in text + assert "tool-b" in text + assert "tool-a" not in text def test_no_deps_in_registry(self, server): """install_dependencies on empty registry reports no tools.""" @@ -573,7 +574,7 @@ def test_save_tool_indexes_into_kb(self, tmp_path): "save_code_spec", { "spec": make_spec( - name="csv_filter", + name="csv-filter", description="Filter CSV rows by column value", ) }, @@ -581,7 +582,7 @@ def test_save_tool_indexes_into_kb(self, tmp_path): results = kb.search("filter", collection=TOOL_REGISTRY_COLLECTION) assert len(results) > 0 - assert any("csv_filter" in r["chunk"].get("text", "") for r in results) + assert any("csv-filter" in r["chunk"].get("text", "") for r in results) def test_search_registry_semantic(self, tmp_path): """Semantic search finds tools by description similarity.""" @@ -591,7 +592,7 @@ def test_search_registry_semantic(self, tmp_path): "save_code_spec", { "spec": make_spec( - name="csv_filter", + name="csv-filter", description="Filter and remove rows from a CSV spreadsheet based on column values", ) }, @@ -600,7 +601,7 @@ def test_search_registry_semantic(self, tmp_path): text = call_tool( server, "search_registry", {"query": "delete rows from tabular data"} ) - assert "csv_filter" in text + assert "csv-filter" in text def test_search_registry_by_tag(self, tmp_path): """Tag-based filtering returns only matching tools.""" diff --git a/tests/test_skills_catalog.py b/tests/test_skills_catalog.py index 334561f..1a3c097 100644 --- a/tests/test_skills_catalog.py +++ b/tests/test_skills_catalog.py @@ -280,6 +280,7 @@ def test_mirror_truncates_long_description(tmp_path): ("goose", ".agents/skills"), ("cline", ".cline/skills"), ("codex", ".agents/skills"), + ("opencode", ".agents/skills"), ], ) def test_setup_skills_mirrors_into_native_dir(tmp_path, agent, subdir): @@ -294,6 +295,45 @@ def test_setup_skills_mirrors_into_native_dir(tmp_path, agent, subdir): assert any("kill" in a for a in actions) # reported a mirror action +def test_setup_skills_mirrors_registered_codes(tmp_path): + """Registered codes share the skill envelope, so they mirror natively too.""" + from dsagt.agents import AGENTS + from dsagt.registry import CodeRegistry + + CodeRegistry(runtime_dir=tmp_path).save_tool( + { + "name": "my-code", + "description": "Use when testing the native code mirror", + "executable": "echo hi", + "parameters": {}, + } + ) + AGENTS["claude"]().setup_skills(tmp_path, {}) + mirrored = tmp_path / ".claude" / "skills" / "my-code" / "SKILL.md" + assert mirrored.exists() + # The mirrored copy carries the exact dsagt-run command the agent must run. + assert "dsagt-run --code my-code -- echo hi" in mirrored.read_text() + + +def test_setup_skills_project_skill_wins_code_name_collision(tmp_path): + """A deliberately installed instruction skill outranks a same-named code.""" + from dsagt.agents import AGENTS + from dsagt.registry import CodeRegistry + + CodeRegistry(runtime_dir=tmp_path).save_tool( + { + "name": "clash", + "description": "the code", + "executable": "echo code", + "parameters": {}, + } + ) + _mkskill(tmp_path / "skills" / "clash", "clash") + AGENTS["claude"]().setup_skills(tmp_path, {}) + text = (tmp_path / ".claude" / "skills" / "clash" / "SKILL.md").read_text() + assert "dsagt-run" not in text # the skill copy, not the code copy + + def test_setup_skills_respects_populate_native_false(tmp_path): from dsagt.agents import AGENTS From bad36fdd801ddb3d4b4799fe2f804f4e86373c5d Mon Sep 17 00:00:00 2001 From: aarontuor Date: Thu, 2 Jul 2026 14:19:41 -0700 Subject: [PATCH 27/44] fix(cline): dsagt never touches cline auth; fix mcp add for cline 3.x MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dsagt init hard-failed for cline without ANTHROPIC_API_KEY / ANTHROPIC_MODEL in the shell — a batch-mode-era requirement, though cline batch is unconditionally unsupported. Worse, running `cline auth -p anthropic` from scavenged env vars can clobber an existing provider integration (e.g. an OpenAI subscription login). BYOA: the user configures cline before pointing dsagt at it; dsagt writes only the MCP config. credential_env_vars/hints dropped. - cline 3.x moved --config to a global option (must precede the subcommand) and made `mcp add` open an interactive wizard without --yes; both fixed (probe-verified against cline 3.0.34). Verified: credential-less `dsagt init --agent cline` completes — instructions written, MCP server registered + env-patched, 2 skills mirrored natively. Co-Authored-By: Claude Fable 5 --- src/dsagt/agents/cline.py | 108 +++++++++----------------------------- tests/test_config.py | 14 +++-- 2 files changed, 35 insertions(+), 87 deletions(-) diff --git a/src/dsagt/agents/cline.py b/src/dsagt/agents/cline.py index 4a14b67..a9f0c51 100644 --- a/src/dsagt/agents/cline.py +++ b/src/dsagt/agents/cline.py @@ -3,8 +3,11 @@ Install: ``npm i -g cline``. Generates: ``.clinerules/dsagt_instructions.md``; -``cline auth`` + ``cline mcp add`` run per-init in -:meth:`ClineSetup.write_dynamic` and write to ``$CLINE_DIR/data/``. +``cline mcp add`` runs per-init in :meth:`ClineSetup.write_dynamic` and +writes to ``$CLINE_DIR/data/``. Provider auth is cline's own +(subscription login / ``cline auth`` / the VS Code extension), configured +by the user before dsagt — dsagt never writes cline auth state, since +doing so can clobber an existing provider integration. **Batch / smoke-test status: NOT SUPPORTED.** Cline's bundled ``lib.mjs`` ships a hardcoded anthropic-provider model whitelist @@ -72,36 +75,13 @@ class ClineSetup(AgentSetup): # is harmless if unused, and search_skills covers the disabled case. native_skills_dir = ".cline/skills" install_hint = "Install with `npm i -g cline`." - # Cline's CLI nominally supports openai-native + anthropic, but cline - # auth's ``-b/--baseurl`` flag is openai-only and the openai-native - # path needs a non-standard model env var. Anthropic is the only - # path with consistent env conventions: ``ANTHROPIC_BASE_URL`` is read - # by cline's anthropic SDK at runtime, covering api.anthropic.com and - # gateway endpoints alike. - credential_env_vars = ( - "ANTHROPIC_API_KEY", - "ANTHROPIC_BASE_URL", - "ANTHROPIC_MODEL", - ) - credential_hints = ( - ( - "ANTHROPIC_API_KEY", - "your provider API key (works for openai-shape " - "gateways too — cline's anthropic SDK reaches them via " - "ANTHROPIC_BASE_URL)", - ), - ( - "ANTHROPIC_BASE_URL", - "gateway / proxy URL " - "(cline auth's -b flag is openai-only; the anthropic SDK reads " - "this env var at runtime)", - ), - ( - "ANTHROPIC_MODEL", - "model name your gateway serves " - "(e.g. claude-haiku-4-5-20251001-v1-project)", - ), - ) + # Cline owns its provider auth (subscription login, `cline auth`, or the + # VS Code extension's settings) — BYOA means the user configures cline + # BEFORE pointing dsagt at it, and dsagt never touches auth state: + # running `cline auth` from scavenged env vars can clobber an existing + # provider integration (e.g. an OpenAI subscription login). + credential_env_vars = () + credential_hints = () def owned_artifacts(self, working_dir: Path) -> list[Path]: return [ @@ -133,65 +113,25 @@ def write_dynamic( working_dir: Path, pdir: Path, ) -> list[str]: - """Three side-effects, all idempotent: + """Two side-effects, both idempotent: - 1. ``cline auth`` — populate per-project auth state from - ``ANTHROPIC_API_KEY`` / ``ANTHROPIC_MODEL`` in the user's - shell. Cline's per-project state dir is empty until ``cline - auth`` writes to it; the user's global cline auth doesn't - propagate into a fresh ``--config /.cline-data``. - 2. ``cline mcp add`` per server (skipped if entry already exists + 1. ``cline mcp add`` per server (skipped if entry already exists — cline's mcp subcommand has no remove, only add). - 3. Patch the JSON cline wrote with our env block (cline doesn't + 2. Patch the JSON cline wrote with our env block (cline doesn't inherit parent env into MCP children, so MLFLOW_TRACKING_URI, DSAGT_PROJECT_DIR, EMBEDDING_* must live in the JSON). + Provider auth is cline's own (subscription login / ``cline auth`` / + the VS Code extension) and must be configured before using dsagt — + dsagt never writes cline auth state (see the class comment). + Requires the ``cline`` binary to be installed at init time. """ - del pdir + del env, pdir actions: list[str] = [] cline_dir = str(working_dir / ".cline-data") Path(cline_dir).mkdir(parents=True, exist_ok=True) - # 1. Configure per-project cline auth from shell env. - api_key = env.get("ANTHROPIC_API_KEY") - model = env.get("ANTHROPIC_MODEL") - if not api_key or not model: - raise RuntimeError( - "cline batch mode requires ANTHROPIC_API_KEY and " - "ANTHROPIC_MODEL in the shell env (and ANTHROPIC_BASE_URL " - "if you're on a gateway). Cline's anthropic SDK reads " - "ANTHROPIC_BASE_URL at runtime, so this single config " - "covers api.anthropic.com and lab gateways alike. For " - "openai-shape gateways like PNNL, alias " - "ANTHROPIC_API_KEY=$OPENAI_API_KEY — most lab gateways " - "serve both wire protocols on the same key." - ) - auth_cmd = [ - "cline", - "auth", - "--config", - cline_dir, - "-p", - "anthropic", - "-k", - api_key, - "-m", - model, - ] - result = subprocess.run( - auth_cmd, - cwd=str(working_dir), - capture_output=True, - text=True, - ) - if result.returncode != 0: - detail = (result.stderr or result.stdout).strip() - raise RuntimeError( - f"cline auth failed (exit {result.returncode}): {detail}" - ) - actions.append(f"Configured cline auth at {cline_dir}") - # Cline's mcp subcommand only has ``add`` (no ``remove``), and ``add`` # errors if the server already exists. Detect pre-existing entries # and skip the add so this method is idempotent (called by both @@ -209,12 +149,16 @@ def write_dynamic( existing = set() if "dsagt" not in existing: + # ``--config`` is a *global* option on cline ≥3.x — it must + # precede the subcommand (``mcp add`` rejects it as unknown). + # ``--yes`` skips the interactive add wizard cline 3.x opens. add_cmd = [ "cline", - "mcp", - "add", "--config", cline_dir, + "mcp", + "add", + "--yes", "dsagt", "--", "uv", diff --git a/tests/test_config.py b/tests/test_config.py index b5fa34e..9be898e 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1131,11 +1131,6 @@ def test_goose_credentials_include_host_not_base_url(self, tmp_path): [ ("claude", "ANTHROPIC_BASE_URL"), ("codex", "OPENAI_BASE_URL"), - # Cline: ``cline auth -b`` is openai-only and the openai-native - # path needs a non-standard model env var, so we standardize on - # the anthropic path — same env conventions as the rest of the - # BYOA story. See cline.py credential_hints. - ("cline", "ANTHROPIC_BASE_URL"), # opencode emits both — its provider config supports both wire # protocols via {env:VAR} interpolation in opencode.json. ("opencode", "OPENAI_BASE_URL"), @@ -1150,6 +1145,15 @@ def test_gateway_url_in_credential_hints(self, agent_name, gateway_var, tmp_path names = [n for n, _ in AGENTS[agent_name]().byoa_env_hints()] assert gateway_var in names + def test_cline_emits_no_credential_hints(self, tmp_path): + """Cline owns its provider auth (subscription login / cline auth / + the VS Code extension) — dsagt surfaces no env-var hints and never + writes cline auth state, since doing so can clobber an existing + provider integration.""" + from dsagt.agents import AGENTS + + assert AGENTS["cline"]().byoa_env_hints() == [] + class TestNoLaunchShim: """Phase 1 collapsed the launch surface: ``dynamic_agent_record`` From 87948635bdd92fd3c6c49292fddcb5fc6977cf32 Mon Sep 17 00:00:00 2001 From: aarontuor Date: Thu, 2 Jul 2026 14:19:41 -0700 Subject: [PATCH 28/44] docs: refresh agent-card.md to v0.2.0 reality; fix stale agent instructions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agent-card.md described the pre-0.2.0 architecture (two MCP servers, LiteLLM proxy, OTel/OTLP, setup-kb/mlflow/memory/stop commands, Roo Code, dsagt_config.yaml, faiss). Rewritten in the same card format: single dsagt-server with all 20 tools enumerated by concern, serverless sqlite MLflow store, BYOA auth, transcript trace pipeline, codes as skill-standard dirs with native mirroring, current agents/deps/CLI, and a skill_discovery capability replacing the no-longer-bundled datacard-generator. dsagt_instructions.md fixes: the 'scientific' skill source doesn't exist (→ k-dense-ai); codes/scripts/ → codes//scripts/; code-name charset rule beside the save_code_spec guidance; and a note that registered codes appear among native skills, whose SKILL.md opens with the exact command — same verbatim-executable rule as section 1b. Co-Authored-By: Claude Fable 5 --- agent-card.md | 356 ++++++++++++++------------------ src/dsagt/dsagt_instructions.md | 14 +- 2 files changed, 166 insertions(+), 204 deletions(-) diff --git a/agent-card.md b/agent-card.md index 1e0eb0e..6bd7ca2 100644 --- a/agent-card.md +++ b/agent-card.md @@ -8,108 +8,107 @@ tags: - science:general - risk:general license: Apache-2.0 -base_model: N/A # DSAgt is agent-platform-agnostic; it wraps Claude Code, Goose, Codex, opencode, Roo Code, or Cline +base_model: N/A # DSAgt is agent-platform-agnostic; it wraps Claude Code, Goose, Codex, opencode, or Cline datasets: - - # NeMo Curator reference corpus (indexed into global KB by dsagt setup-kb) - - # AI Data Readiness Inspector (AIDRIN) reference corpus + - # NeMo Curator reference corpus (optional knowledge collection, indexed at dsagt init) + - # AI Data Readiness Inspector (AIDRIN) reference corpus (optional knowledge collection) metrics: - - # Tool registration success rate + - # Code registration success rate - # Knowledge base search precision - # Provenance trace completeness agent_card: name: "DSAgt (DataSmith Agent)" - description: "AI-assisted data pipeline builder developed under the DOE Genesis Mission. Wraps an MCP-compatible agent CLI with tool registration, a semantic knowledge base, execution provenance, and observability infrastructure to accelerate AI-ready scientific data preparation." + description: "AI-assisted data pipeline builder developed under the DOE Genesis Mission. Wraps an MCP-compatible agent CLI with code registration, a semantic knowledge base, skill discovery, execution provenance, and observability infrastructure to accelerate AI-ready scientific data preparation." provider: organization: "DOE AI ModCon Base Data Team (DOE Genesis Mission)" url: "https://github.com/AI-ModCon/dsagt" - version: "0.1.0" + version: "0.2.0" documentation_url: "https://ai-modcon.github.io/dsagt/" protocol_version: "N/A" # DSAgt extends existing agent CLIs via MCP; it is not itself an A2A service - preferred_transport: "HTTP" + preferred_transport: "stdio" capabilities: streaming: false push_notifications: false - state_transition_history: true # trace_archive + MLflow record full execution history + state_transition_history: true # trace_archive + the serverless MLflow store record full execution history authentication: - schemes: - - "Bearer" # used by the optional LiteLLM proxy and hosted embedding backends - credentials: "N/A" # public repo; agent CLI auth is handled by the underlying platform (Claude, Goose, etc.) + schemes: [] # BYOA — the agent platform owns its own LLM-provider auth; DSAgt never proxies or stores credentials + credentials: "N/A (optional: EMBEDDING_API_KEY in the shell for a hosted embedding backend — never written to disk)" default_input_modes: - "text/plain" default_output_modes: - "text/plain" - - "application/json" # tool execution records, pipeline reconstructions + - "application/json" # code execution records, pipeline reconstructions skills: - - id: "tool_registration" - name: "Tool Registration" - description: "Register CLI tools as markdown files with YAML frontmatter; install dependencies via uv; wrap executions with dsagt-run for provenance." - tags: [registry, provenance, mcp] + - id: "code_registration" + name: "Code Registration" + description: "Register CLI codes as skill-standard directories (codes//SKILL.md) with machine-readable parameters; install dependencies via uv; every execution is wrapped with dsagt-run for provenance. Registered codes are also mirrored into the agent's native skills directory for in-context discovery." + tags: [registry, provenance, mcp, skills] examples: - - "Register csvkit tools csvcut, csvgrep, csvstat, csvlook." + - "Register this analysis script as a reusable code." - "Install Python dependencies for a custom analysis script." input_modes: ["text/plain"] output_modes: ["text/plain", "application/json"] - id: "knowledge_base" name: "Knowledge Base" - description: "Hybrid dense+sparse semantic search over six ChromaDB collections: Tool Specs, Skills, Domain Knowledge, Explicit Memory, Episodic Memory, and Tool Use Records." + description: "Hybrid dense+sparse (sentence-transformers + BM25) semantic search over ChromaDB collections: code specs, skill catalogs, domain knowledge, code-use records, and session memory. Optional cross-encoder reranking and regex/substring document filters." tags: [knowledge, chromadb, semantic-search, mcp] examples: - "Ingest domain documentation into a named collection." - - "Search for tools matching 'CSV statistics'." - - "Save a user-confirmed fact to explicit memory." + - "Search for codes matching 'CSV statistics'." input_modes: ["text/plain"] output_modes: ["text/plain", "application/json"] - id: "provenance" name: "Execution Provenance" - description: "Record every tool invocation (command, args, exit code, duration, file counts, stderr) to trace_archive/ and emit OTLP spans to MLflow. Reconstruct the full execution history as a bash script or Snakemake workflow." - tags: [provenance, mlflow, otlp, reproducibility] + description: "Record every code invocation (command, stdout/stderr, exit code, timing, file I/O) to trace_archive/ and emit spans to the project's serverless MLflow store. Reconstruct the full execution history as a dependency-ordered pipeline." + tags: [provenance, mlflow, reproducibility] examples: - - "Reconstruct a reproducible pipeline from prior tool executions." - - "View tool execution traces in the MLflow UI." + - "Reconstruct a reproducible pipeline from prior code executions." + - "View execution traces in the MLflow UI." input_modes: ["text/plain"] - output_modes: ["text/plain", "application/x-sh", "text/x-snakemake"] + output_modes: ["text/plain", "application/json"] - - id: "episodic_memory" - name: "Episodic Memory Distillation" - description: "Distill new MLflow traces into per-category episodic memory using embedding-centroid outlier detection. Surfaces novel facts from prior sessions." - tags: [memory, mlflow, distillation] + - id: "memory" + name: "Explicit + Episodic Memory" + description: "Explicit memory stores user-confirmed facts (YAML + vector mirror) via kb_remember / kb_get_memories. Opt-in episodic memory mechanically chunks, tags, and embeds every session turn into a recency-weighted searchable collection — no LLM calls." + tags: [memory, chromadb] examples: - - "Distill today's session traces into episodic memory." + - "Put this in explicit memory: samples.csv has null values in the status column." + - "What do you remember about the samples dataset?" input_modes: ["text/plain"] output_modes: ["text/plain"] - - id: "datacard_generator" - name: "Datacard Generator" - description: "Bundled skill workflow for generating standardized dataset documentation (datacards) from indexed knowledge and agent-driven analysis." - tags: [datacard, documentation, skill] + - id: "skill_discovery" + name: "Skill Discovery and Installation" + description: "Search external skill catalogs (Genesis, Anthropic, K-Dense, and others cloned+indexed at init) and install skills into the project, where the agent auto-discovers them natively. Agents can also author and save their own skills." + tags: [skills, catalog, mcp] examples: - - "Generate a datacard for samples.csv." + - "Search the skill catalog for a literature-search skill and install it." input_modes: ["text/plain"] output_modes: ["text/plain", "text/markdown"] Extensions: agent_runtime: - framework: "MCP (Model Context Protocol) over stdio; supported agent platforms: Claude Code, Goose, Codex, opencode, Roo Code, Cline" - service_endpoint: "stdio (MCP servers launched as subprocesses by the agent platform)" - rate_limits: "Determined by the underlying LLM provider and optional LiteLLM proxy configuration." - logging: "OTLP HTTP spans emitted to local MLflow instance (http://localhost:/v1/traces); full tool execution records written to /trace_archive/" - memory: "Stateful per-project. Explicit memory: /explicit_memories.yaml + ChromaDB. Episodic memory: ChromaDB (distilled by dsagt memory). Tool use records: /trace_archive/ + ChromaDB." + framework: "MCP (Model Context Protocol) over stdio; supported agent platforms: Claude Code, Goose, Codex, opencode, Cline" + service_endpoint: "stdio (the single dsagt-server is launched as a subprocess by the agent platform)" + rate_limits: "Determined by the underlying LLM provider configured in the agent platform (BYOA — DSAgt never proxies LLM traffic)." + logging: "Serverless MLflow store at sqlite:////mlflow.db (no server to run); full code execution records written to /trace_archive/. Agent LLM-call history is recovered post-hoc from the agent's on-disk transcript." + memory: "Stateful per-project. Explicit memory: /.dsagt/explicit_memories.yaml + ChromaDB mirror. Episodic memory (opt-in): session_memory ChromaDB collection, embedded per turn. Code-use records: /trace_archive/ + code_use ChromaDB collection." --- # DSAgt (DataSmith Agent) -DSAgt is an AI-assisted data pipeline builder. It connects an MCP-compatible agent CLI (Claude Code, Goose, Codex, opencode, Roo Code, or Cline) to tool registration, a semantic knowledge base, execution provenance, and observability infrastructure — without modifying the agent itself. +DSAgt is an AI-assisted data pipeline builder. It connects an MCP-compatible agent CLI (Claude Code, Goose, Codex, opencode, or Cline) to code registration, a semantic knowledge base, skill discovery, execution provenance, and observability infrastructure — without modifying the agent itself. -*Last Updated*: **2026-06-30** +*Last Updated*: **2026-07-02** ## Developed by @@ -129,28 +128,29 @@ See https://github.com/AI-ModCon/dsagt/graphs/contributors for full list. ## Agent Changelog ++ **2026-07-02** v0.2.0 — single merged `dsagt-server` (20 tools); serverless SQLite MLflow store (no ports, no OTel, no proxy); external skill catalogs; agent-transcript trace pipeline + opt-in episodic memory; codes stored as skill-standard directories mirrored into the agent's native skills dir + **2026-06-30** initial public version (v0.1.0) ## Agent short description -Tool-using scaffolding layer that gives any MCP-compatible agent CLI persistent tool registration, semantic knowledge retrieval, execution provenance, and session memory — exposed via two MCP servers (Registry and Knowledge). +Scaffolding layer that gives any MCP-compatible agent CLI persistent code registration, semantic knowledge retrieval, skill discovery, execution provenance, and session memory — exposed as 20 tools on a single MCP server (`dsagt-server`). ## Agent description -DSAgt wraps an unmodified agent CLI with four independently-operable layers, each implemented as an MCP server the agent discovers through the standard MCP tool protocol: +DSAgt wraps an unmodified agent CLI with four independently-operable concerns, exposed by one MCP server the agent discovers through the standard MCP tool protocol: -1. **Tool Registry** — The agent registers CLI tools as markdown files with YAML frontmatter; the registry server handles dependency installation (`uv run --with`) and wraps each invocation with `dsagt-run` for provenance. Discovery is via `search_registry`. -2. **Knowledge Base** — Six independently-partitioned ChromaDB collections with hybrid dense (sentence-transformers) + sparse (BM25) search. Global collections (Tool Specs, Skills, Domain Knowledge) are populated by `dsagt setup-kb`; per-project collections (Explicit Memory, Episodic Memory, Tool Use Records) fill in during use. Discovery is via `kb_search`, `kb_ingest`, `kb_remember`, `search_skills`. -3. **Provenance** — `dsagt-run` captures every tool execution (command, args, exit code, duration, file I/O, stderr) to `trace_archive/` and emits OTLP spans to MLflow. `reconstruct_pipeline` renders the archive as a reproducible bash or Snakemake script. -4. **Observability** — Local MLflow instance with OTLP ingestion. All four layers emit spans. The agent's own LLM-call traces land in the same store when `OTEL_EXPORTER_OTLP_ENDPOINT` is set (printed by `dsagt init`). +1. **Code Registry** — The agent registers CLI codes as skill-standard directories (`codes//SKILL.md`, frontmatter carrying executable + parameters); the server handles dependency installation (`uv run --with`) and wraps each stored command with `dsagt-run` for provenance. Discovery is dual-path: semantic search via `search_registry`, plus a mirror into the agent's native skills directory so the exact runnable command is in context at invocation time. +2. **Knowledge Base** — ChromaDB collections with hybrid dense (sentence-transformers) + sparse (BM25) search and optional cross-encoder reranking. Code specs and selected skill catalogs are indexed at `dsagt init`; per-project collections (code-use records, session memory) fill in during use. Long ingests run as background jobs. +3. **Provenance** — `dsagt-run` captures every code execution (command, stdout/stderr, exit code, timing, file I/O) to `trace_archive/` and emits spans to the project's serverless MLflow store. `reconstruct_pipeline` renders the archive as a dependency-ordered execution history. +4. **Observability & Memory** — All self-logging lands in `sqlite:////mlflow.db` (MLflow's serverless backend — nothing to run). Agent LLM-call traces are recovered post-hoc from the agent's on-disk transcript, uniformly across all five platforms. Explicit memory stores user-confirmed facts; opt-in episodic memory embeds every session turn for recency-weighted recall. -The data layer is agent-platform-agnostic: switching platforms preserves all accumulated knowledge, tools, and traces. +The data layer is agent-platform-agnostic: switching platforms preserves all accumulated knowledge, codes, skills, and traces. ## Underlying model(s) -- Primary model(s): N/A — DSAgt is platform-agnostic and delegates LLM calls to the configured agent CLI (Claude, GPT-4o, etc.) -- Embedding model: `sentence-transformers` (local, CPU-side, default); optionally any LiteLLM-compatible hosted embedder -- Cross-encoder reranking: optional, enabled via `knowledge.rerank: true` in `dsagt_config.yaml` +- Primary model(s): N/A — DSAgt is platform-agnostic and delegates LLM calls to the configured agent CLI (BYOA; DSAgt never proxies LLM traffic) +- Embedding model: `sentence-transformers` (`bge-small-en-v1.5`, local, CPU-side, default); optionally any OpenAI-compatible hosted embedder (`embedding.backend: api`) +- Cross-encoder reranking: optional, enabled via `knowledge.rerank: true` in `.dsagt/config.yaml` ## Inputs and outputs @@ -159,121 +159,90 @@ The data layer is agent-platform-agnostic: switching platforms preserves all acc - defaultInputModes: `["text/plain"]` - defaultOutputModes: `["text/plain", "application/json"]` -The agent accepts natural-language instructions (text). Outputs include text responses, registered tool specs (markdown), execution trace records (JSON), pipeline reconstructions (bash / Snakemake), and datacard documents (markdown). +The agent accepts natural-language instructions (text). Outputs include text responses, registered code specs (skill-standard markdown), execution trace records (JSON), pipeline reconstructions, and installed skills (markdown). ### Skills -- **Skill ID**: `tool_registration` - **Name**: Tool Registration - **Description**: Register CLI tools as markdown+YAML specs; install dependencies; wrap executions with `dsagt-run` for provenance. - **Tags**: registry, provenance, mcp - **Examples**: "Register csvkit tools csvcut, csvgrep, csvstat, csvlook.", "Install Python dependencies for a custom analysis script." +- **Skill ID**: `code_registration` + **Name**: Code Registration + **Description**: Register CLI codes as skill-standard spec directories; install dependencies; wrap executions with `dsagt-run` for provenance; mirror specs into the agent's native skills dir. + **Tags**: registry, provenance, mcp, skills + **Examples**: "Register this analysis script as a reusable code.", "Install Python dependencies for a custom analysis script." **Input/Output Modes**: text/plain → text/plain, application/json - **Skill ID**: `knowledge_base` **Name**: Knowledge Base - **Description**: Hybrid semantic search (dense + BM25) over Tool Specs, Skills, Domain Knowledge, Explicit Memory, Episodic Memory, and Tool Use Records collections. + **Description**: Hybrid semantic search (dense + BM25, optional reranking, regex/substring filters) over code specs, skill catalogs, domain knowledge, code-use records, and session memory. **Tags**: knowledge, chromadb, semantic-search, mcp - **Examples**: "Ingest domain documentation into a named collection.", "Search for tools matching 'CSV statistics'.", "Save a user-confirmed fact to explicit memory." + **Examples**: "Ingest domain documentation into a named collection.", "Search for codes matching 'CSV statistics'." **Input/Output Modes**: text/plain → text/plain, application/json - **Skill ID**: `provenance` **Name**: Execution Provenance - **Description**: Record every tool invocation to `trace_archive/` + MLflow; reconstruct full execution history as a bash script or Snakemake workflow. - **Tags**: provenance, mlflow, otlp, reproducibility - **Examples**: "Reconstruct a reproducible pipeline from prior tool executions." - **Input/Output Modes**: text/plain → text/plain, application/x-sh - -- **Skill ID**: `episodic_memory` - **Name**: Episodic Memory Distillation - **Description**: Distill MLflow traces into per-category episodic memory using embedding-centroid outlier detection (`dsagt memory --project `). - **Tags**: memory, mlflow, distillation - **Examples**: "Distill today's session traces into episodic memory." + **Description**: Record every code invocation to `trace_archive/` + the serverless MLflow store; reconstruct the full execution history as a dependency-ordered pipeline. + **Tags**: provenance, mlflow, reproducibility + **Examples**: "Reconstruct a reproducible pipeline from prior code executions." + **Input/Output Modes**: text/plain → text/plain, application/json + +- **Skill ID**: `memory` + **Name**: Explicit + Episodic Memory + **Description**: User-confirmed facts via `kb_remember` / `kb_get_memories` (YAML + vector mirror); opt-in episodic memory embeds every turn for recency-weighted cross-session recall (mechanical — no LLM calls). + **Tags**: memory, chromadb + **Examples**: "Put this in explicit memory: samples.csv has null values in the status column." **Input/Output Modes**: text/plain → text/plain -- **Skill ID**: `datacard_generator` - **Name**: Datacard Generator - **Description**: Bundled skill workflow (`src/dsagt/skills/datacard-generator/`) for generating standardized dataset documentation. - **Tags**: datacard, documentation, skill - **Examples**: "Generate a datacard for samples.csv." - **Input/Output Modes**: text/plain → text/markdown +- **Skill ID**: `skill_discovery` + **Name**: Skill Discovery and Installation + **Description**: Search external skill catalogs cloned + indexed at init (Genesis, Anthropic, K-Dense, Composio, and others); install skills into the project for native auto-discovery; save agent-authored skills. + **Tags**: skills, catalog, mcp + **Examples**: "Search the skill catalog for a literature-search skill and install it." + **Input/Output Modes**: text/plain → text/plain, text/markdown ### Tools and permissions -- Tool: `search_registry` (Registry MCP server) - - Purpose: Semantic search over registered tool specs - - Inputs: query string, optional tag filters - - Outputs: ranked list of matching tool specs - - Side effects: reads data - - Required permissions: none - -- Tool: `save_tool_spec` (Registry MCP server) - - Purpose: Register a new CLI tool as a markdown file with YAML frontmatter - - Inputs: tool name, command, dependencies, tags, usage description - - Outputs: path to written tool spec file - - Side effects: writes data to `/tools/` - - Required permissions: filesystem write access to project directory - -- Tool: `install_dependencies` (Registry MCP server) - - Purpose: Install tool dependencies via `uv run --with` - - Inputs: list of package names - - Outputs: installation confirmation - - Side effects: executes jobs (uv), network calls (PyPI) - - Required permissions: network access, uv installed - -- Tool: `reconstruct_pipeline` (Registry MCP server) - - Purpose: Render the trace archive as a reproducible bash script or Snakemake workflow - - Inputs: optional time range or session filter - - Outputs: bash or Snakemake script - - Side effects: reads data from `/trace_archive/` - - Required permissions: none - -- Tool: `kb_search` (Knowledge MCP server) - - Purpose: Hybrid semantic search over one or more knowledge collections - - Inputs: query string, collection name(s), optional filters - - Outputs: ranked document chunks with metadata - - Side effects: reads data - - Required permissions: none - -- Tool: `kb_ingest` (Knowledge MCP server) - - Purpose: Index a file or directory into a named ChromaDB collection (runs in background for large corpora) - - Inputs: file path or directory, collection name - - Outputs: ingestion job ID; status queryable - - Side effects: reads data, writes data to `/kb_index/` - - Required permissions: filesystem read access to source files - -- Tool: `kb_remember` (Knowledge MCP server) - - Purpose: Save a user-confirmed fact to explicit memory - - Inputs: fact string - - Outputs: confirmation - - Side effects: writes data to `/explicit_memories.yaml` and ChromaDB - - Required permissions: none - -- Tool: `kb_get_memories` (Knowledge MCP server) - - Purpose: Retrieve explicit and episodic memories for the current project - - Inputs: optional query string - - Outputs: list of memory entries - - Side effects: reads data - - Required permissions: none - -- Tool: `search_skills` (Knowledge MCP server) - - Purpose: Discover agent skill workflows - - Inputs: query string - - Outputs: ranked list of matching skills with SKILL.md content - - Side effects: reads data - - Required permissions: none +All 20 tools live on the single `dsagt-server` (stdio), split across four concerns. + +**Registry (8):** + +- `search_registry` — semantic search over registered + bundled code specs. Side effects: reads data. +- `get_registry` — list every registered code with its MCP-compatible schema. Side effects: reads data. +- `save_code_spec` — register a code as `codes//SKILL.md` (executable auto-wrapped with `dsagt-run` + `uv run --with`). Side effects: writes to the project dir; indexes into ChromaDB. +- `install_dependencies` — install a code's Python dependencies via uv. Side effects: executes uv, network calls (PyPI). +- `run_command` — execute a shell command with a timeout. Side effects: executes subprocesses. +- `read_file` — read a file from disk. Side effects: reads data. +- `http_request` — issue an HTTP(S) request. Side effects: network calls. +- `reconstruct_pipeline` — render `trace_archive/` as a dependency-ordered execution history. Side effects: reads data. + +**Knowledge (5):** + +- `kb_search` — hybrid semantic search over one or more collections (optional metadata, regex, and substring filters). Side effects: reads data. +- `kb_ingest` — index a file or directory into a named collection (background job for large corpora). Side effects: reads sources, writes `/kb_index/`. +- `kb_append` — add documents to an existing collection (background job). Side effects: writes `/kb_index/`. +- `kb_list_collections` — list collections with document counts. Side effects: reads data. +- `kb_job_status` — poll a background ingest/append job. Side effects: none. + +**Memory (2):** + +- `kb_remember` — save a user-confirmed fact to explicit memory. Side effects: writes `/.dsagt/explicit_memories.yaml` + ChromaDB. +- `kb_get_memories` — retrieve explicit memories (optionally query-filtered). Side effects: reads data. + +**Skills (5):** + +- `search_skills` — rank installable skills across synced external catalogs. Side effects: reads data. +- `install_skill` — copy a catalog skill into `/skills/` (with upstream attribution). Side effects: writes to the project dir. +- `save_skill` — register an agent-authored skill into `/skills/`. Side effects: writes to the project dir. +- `add_skill_source` — clone + index a new external skill catalog. Side effects: network calls (git), writes `kb_index/`. +- `list_skill_sources` — list known/synced catalog sources. Side effects: reads data. ### Service endpoint and discovery - Base URL: `https://github.com/AI-ModCon/dsagt` -- MCP servers run as local subprocesses; there is no remote HTTP endpoint by default. -- Registry server: `dsagt-registry-server` (stdio) -- Knowledge server: `dsagt-knowledge-server` (stdio) -- Optional LiteLLM proxy: `dsagt-proxy` (HTTP, for routing embeddings/LLM calls to hosted providers) +- The MCP server runs as a local subprocess; there is no remote HTTP endpoint. +- Server: `dsagt-server` (stdio), self-sufficient — it derives the project from its working directory (`.dsagt/config.yaml`). ## Runtime Infrastructure -DSAgt runs locally as a CLI tool. Both MCP servers are launched as subprocesses by the configured agent platform. MLflow runs locally at a port pinned at `dsagt init` time. +DSAgt runs locally as a CLI tool. The MCP server is launched as a subprocess by the configured agent platform. There are no services to run: all self-logging goes to a serverless SQLite MLflow store (`sqlite:////mlflow.db`), browsable on demand with `mlflow ui --backend-store-uri sqlite:////mlflow.db`. ### Hardware @@ -281,25 +250,17 @@ Runs on any developer workstation or compute node with Python 3.12+. The default ### Software -Python 3.12+, `uv` package manager. Key dependencies: +Python 3.12 or 3.13, `uv` package manager. Key dependencies: - `mcp>=1.0.0` — MCP server framework +- `mlflow==3.11.1` — trace store and observability (serverless SQLite backend) - `chromadb>=1.5.1` — vector store -- `faiss-cpu>=1.8` — FAISS index backend -- `sentence-transformers==5.4.0` — local embedding model -- `llama-index-core>=0.11` — document chunking and indexing -- `mlflow>=3.11.1,<4.0` — observability and trace storage -- `opentelemetry-*>=1.27` — OTLP span emission -- `litellm[proxy]>=1.83.7` — optional hosted embedder / LLM proxy routing +- `sentence-transformers==5.4.0` — local embeddings and reranking +- `llama-index-core>=0.11` — document and code chunking - `rank-bm25>=0.2.2` — sparse keyword retrieval for hybrid search +- `questionary>=2.0` — interactive `dsagt init` menus -```txt -# To get full dependency list: -uv sync --all-groups -pip freeze -``` - -See `pyproject.toml` for the complete pinned dependency set. +See `pyproject.toml` for the complete dependency set. ## Papers and Scientific Outputs @@ -331,107 +292,106 @@ Tested Use cases include: - Microbial genomics pipelines (short-read QC and assembly) - Cryo-EM data curation (EMPIAR datasets) - Materials science DFT workflows (VASP via ISAAC) -- Tokamak stability analysis (fusion energy) -- Combustion flow simulations (BlastNet) +- Tokamak stability analysis (fusion energy, M3D-C1) +- AI data readiness assessment (AIDRIN) ## Out-of-Scope Use Cases -- Using DSAgt on controlled or proprietary data that should not be shared with configured LLM inference provider. +- Using DSAgt on controlled or proprietary data that should not be shared with the configured LLM inference provider. # How to use ## Install Instructions ```bash +# For use: +pip install "git+https://github.com/AI-ModCon/dsagt.git" + +# For development: git clone https://github.com/AI-ModCon/dsagt.git cd dsagt -uv sync +uv sync --all-groups source .venv/bin/activate -# Build shared knowledge base collections (Tool Specs, Skills, Domain Knowledge): -dsagt setup-kb - -# Create a project: -dsagt init my-project --agent claude # or goose / codex / opencode / roo / cline +# Create a project (interactive: pick agent platform, knowledge +# collections, and skill-catalog sources; the knowledge base is +# provisioned on first run): +dsagt init ``` ## Agent configuration -- **System prompt / instructions**: generated by `dsagt init` as `CLAUDE.md` (Claude Code), `AGENTS.md` (Codex/opencode), `.goosehints` (Goose), or `.roomodes` (Roo Code) -- **MCP server config**: generated by `dsagt init` as `.mcp.json` (Claude Code), `goose.yaml`, `.codex-data/config.toml`, `opencode.json`, or `.roo/mcp.json` -- **Embedding backend**: set `embedding.backend: api` in `/dsagt_config.yaml` to route through a hosted embedder via LiteLLM; provide `embedding.base_url` and `embedding.api_key` -- **Reranking**: set `knowledge.rerank: true` in `dsagt_config.yaml` -- **Observability**: `dsagt mlflow ` prints `OTEL_EXPORTER_OTLP_ENDPOINT` and `MLFLOW_TRACKING_URI` exports for the agent's own traces +- **System prompt / instructions**: generated by `dsagt init` as `CLAUDE.md` (Claude Code), `AGENTS.md` (Codex/opencode), `.goosehints` (Goose), or `.clinerules/dsagt_instructions.md` (Cline) +- **MCP server config**: generated by `dsagt init` as `.mcp.json` (Claude Code), `goose.yaml`, `.codex-data/config.toml`, `opencode.json`, or via `cline mcp add` +- **LLM provider auth**: owned entirely by the agent platform (BYOA) — configure the agent before pointing DSAgt at it; DSAgt never stores or proxies credentials +- **Embedding backend**: set `embedding.backend: api` in `.dsagt/config.yaml` to use an OpenAI-compatible hosted embedder; the key comes from `EMBEDDING_API_KEY` in the shell (never on disk) +- **Reranking**: set `knowledge.rerank: true` in `.dsagt/config.yaml` +- **Episodic memory**: opt in at init (`dsagt init --episodic` or the interactive prompt) ## Invocation / integration ```bash -# Start MLflow and get OTel exports: -dsagt mlflow my-project +# Launch the agent from the project directory (no env exports, no services): +cd ~/dsagt-projects/my-project && claude # …or goose / codex / opencode / cline -# Paste the printed export block, then launch the agent from the project directory: -cd ~/dsagt-projects/my-project && claude +# Or let dsagt own the launch + post-session extraction trigger: +dsagt start my-project ``` -The agent discovers DSAgt tools via MCP and can invoke `search_registry`, `kb_search`, `save_tool_spec`, etc. directly in the conversation. +The agent discovers DSAgt tools via MCP and can invoke `search_registry`, `kb_search`, `save_code_spec`, etc. directly in the conversation. Registered codes and installed skills also appear in the agent's native skills directory after the next `dsagt start`. # Code snippets of how to use the agent ```bash # Full quickstart (see README for step-by-step prompts): -export SMOKE_DIR="$(pwd)/tests/smoke_test" dsagt init quickstart --agent claude -dsagt mlflow quickstart -# paste printed exports, then: -cd ~/dsagt-projects/quickstart && claude +dsagt start quickstart # After the session: -dsagt memory --project quickstart -dsagt stop quickstart -dsagt info quickstart +dsagt info quickstart # config + session/trace summary +mlflow ui --backend-store-uri sqlite:///$HOME/dsagt-projects/quickstart/mlflow.db -# Non-interactive smoke test: +# Non-interactive smoke test (asserts every artifact): dsagt smoke-test --agent claude ``` ```python -# DSAgt MCP servers are invoked by the agent platform, not directly from Python. +# The DSAgt MCP server is invoked by the agent platform, not directly from Python. # To integrate programmatically, use the MCP Python SDK: # from mcp.client.stdio import StdioServerParameters -# See docs/mcp-servers.md for server command and tool schemas. +# Server command: dsagt-server (run from the project directory). ``` # Limitations ## Risks -DSAgt executes arbitrary CLI tools registered by the agent. The registry server wraps tool invocations with `dsagt-run`, but does not sandbox or restrict what commands the agent can register or execute. Users should review tool specs before registration and restrict filesystem access as appropriate. +DSAgt executes arbitrary CLI codes registered by the agent. The registry wraps code invocations with `dsagt-run` for provenance, but does not sandbox or restrict what commands the agent can register or execute. Users should review code specs before registration and restrict filesystem access as appropriate. ### Agent-specific risk notes (tool use) -- **Tool execution side effects**: Registered tools can read/write files, make network calls, and execute arbitrary subprocesses. The agent must be trusted to register only appropriate tools. -- **Prompt injection**: Knowledge base documents are retrieved and injected into the agent context; malicious content in indexed documents could influence agent behavior. -- **Secrets handling**: API keys for hosted embedding backends are stored in `dsagt_config.yaml`. Do not commit this file to version control if it contains secrets. The LiteLLM proxy credential file is similarly sensitive. +- **Code execution side effects**: Registered codes can read/write files, make network calls, and execute arbitrary subprocesses. The agent must be trusted to register only appropriate codes. +- **Prompt injection**: Knowledge base documents and installed catalog skills are retrieved and injected into the agent context; malicious content in indexed documents or third-party skill catalogs could influence agent behavior. +- **Secrets handling**: No credentials are written to disk by DSAgt. A hosted embedding backend reads `EMBEDDING_API_KEY` from the shell at runtime. - **Data exfiltration**: If a hosted embedding backend is configured, document chunks are sent to that external service during ingestion and search. ## Limitations - Local-first: designed for single-user local or HPC use; no multi-user access control - Embedding model quality: default local `sentence-transformers` model (~130 MB) is effective for general text but may underperform on highly domain-specific technical corpora -- Agent OTel coverage varies: Claude Code and Goose emit full LLM-call traces; Codex emits token counts and tool names; opencode emits nothing natively -- Memory distillation requires MLflow traces to be present; sessions without MLflow running will not generate episodic memories +- Agent LLM-call traces are recovered post-hoc from the agent's on-disk transcript (uniform across all five platforms) — recovery granularity follows what each platform records +- Cline batch mode is unsupported (cline's provider rewrites unrecognized model names); interactive cline use works - No GUI: all interaction is through the agent CLI or the MLflow web UI # Agent evaluation details -- **Smoke test**: `dsagt smoke-test --agent ` runs the full quickstart non-interactively and asserts all artifacts (tool specs, trace records, explicit memory) are produced -- **Unit and integration tests**: `pytest tests/` (requires `uv sync --all-groups`); integration tests marked with `@pytest.mark.integration` can be deselected with `-m 'not integration'` -- **Tool-call correctness**: verified by checking `trace_archive/` records for expected exit codes and file counts -- **Knowledge base precision**: evaluated informally via `kb_search` recall in the smoke test +- **Smoke test**: `dsagt smoke-test --agent ` runs two full agent sessions non-interactively and asserts 18 artifacts: code registration + execution provenance, knowledge ingest + retrieval, skill catalog install, native skill mirroring, explicit + episodic memory, cross-session recall, agent-trace recovery, and session state +- **Unit tests**: `uv run python -m pytest tests/` (~620 tests; integration tests requiring credentials live in `test_*_integration.py`) +- **Code-call correctness**: verified by checking `trace_archive/` records for expected exit codes and captured output +- **Knowledge base precision**: evaluated via retrieval assertions in the smoke test (the agent must answer from ingested docs) # More Information - Full documentation: https://ai-modcon.github.io/dsagt/ - Source code: https://github.com/AI-ModCon/dsagt -- Architecture diagram: `docs/assets/architecture.png` -- Use case walkthroughs: `use_cases/` (Microbial Isolates, Cryo-EM, ISAAC/VASP, Tokamak Stability, Combustion) +- Use case walkthroughs: `use_cases/` (Microbial Isolates, Cryo-EM, ISAAC/VASP, Tokamak Stability, AIDRIN) diff --git a/src/dsagt/dsagt_instructions.md b/src/dsagt/dsagt_instructions.md index 4c81816..1b4a782 100644 --- a/src/dsagt/dsagt_instructions.md +++ b/src/dsagt/dsagt_instructions.md @@ -15,7 +15,7 @@ All data operations must be performed by calling registered codes. The point is **Whenever the user says "what do you remember", "recall", or asks you to retrieve a previously-stored fact, you MUST call `kb_get_memories()` first** and answer based on its result, not from in-context message history. ### 1b. Registered-Code Invocation: Use the `executable` String Verbatim -**When invoking a registered code, copy the spec's `executable` field byte-for-byte, including any `dsagt-run --code --` prefix.** The prefix is the wrapper that writes the execution record to `trace_archive/`; bypassing it (e.g. running `python codes/scan_directory.py` directly when the spec says `dsagt-run --code scan_directory -- python codes/scan_directory.py`) loses provenance and breaks pipeline reconstruction. If `dsagt-run` errors with "command not found", surface the error rather than working around it. +**When invoking a registered code, copy the spec's `executable` field byte-for-byte, including any `dsagt-run --code --` prefix.** The prefix is the wrapper that writes the execution record to `trace_archive/`; bypassing it (e.g. running the bare script directly when the spec says `dsagt-run --code scan-directory -- python -m dsagt.codes.scan_directory`) loses provenance and breaks pipeline reconstruction. If `dsagt-run` errors with "command not found", surface the error rather than working around it. ### 2. Code and Skill Discovery @@ -27,9 +27,11 @@ Before implementing anything, search for existing capabilities: **Skills come in two tiers.** *Installed* skills (in this project) are discovered **natively** by your platform — their names/descriptions are already in your context and you auto-invoke them; you do NOT need `search_skills` to find those. Separately there is a much larger *external catalog* of installable skills (entries marked `[catalog]`), NOT loaded into context. +**Registered codes also appear among your native skills** (mirrored at `dsagt start`). A code's SKILL.md is an execution instruction: it opens with the exact shell command to run. When you invoke a code from its native skill entry, copy that command byte-for-byte — the same verbatim-`executable` rule as section 1b. + **The external catalog is opt-in and starts empty — sources must be synced before `search_skills` can see them.** A blank/weak `search_skills` result usually means the relevant source isn't synced yet, NOT that no such skill exists. So before concluding the catalog has nothing, call `list_skill_sources()` — it reports each known source with its `synced` flag and `indexed` count. The flow: -1. `list_skill_sources()` — see which sources are already synced vs only `available` (known name + URL, not yet indexed). For materials/chem/bio/DFT skills, the `scientific` source (K-Dense) is the one to enable. +1. `list_skill_sources()` — see which sources are already synced vs only `available` (known name + URL, not yet indexed). For materials/chem/bio/DFT skills, the `k-dense-ai` source is the one to enable. 2. `add_skill_source(source=...)` — sync a source (a known name like `scientific`/`anthropic`, or a GitHub URL). Read-only indexing step; nothing is installed into the project. Only needed for sources whose `synced` is false. 3. `search_skills(query)` — now browse the synced catalog. Entries marked `[catalog]` are installable. 4. `install_skill(skill_name=...)` — copy a catalog skill into the project. Its SKILL.md + scripts land on disk immediately, so you can **use it this session** by reading `skills//SKILL.md` and following it. A restart (next `dsagt start`) is only needed for hands-free *native* auto-invocation, not for use. @@ -52,7 +54,7 @@ To author a brand-new skill instead of installing one, use the bundled `skill-cr Booleans render as a bare flag when truthy, nothing when falsy. -When registering a new code via `save_code_spec`, set the `cli` field on every parameter so the next invocation doesn't have to guess. +When registering a new code via `save_code_spec`, set the `cli` field on every parameter so the next invocation doesn't have to guess. Code names use lowercase letters, digits, and hyphens (e.g. `scan-directory`) — the skill-standard charset, since registered codes are mirrored into your native skills directory. ### 3. Code Preference Hierarchy @@ -60,7 +62,7 @@ When implementing any data operation, follow this hierarchy: 1. **REGISTERED CODE** — Use an existing code (`search_registry`) 2. **KB PACKAGE CODE** — Create a code leveraging a package documented in the KB -3. **CUSTOM IMPLEMENTATION** — Write custom code to `codes/scripts/` and register it +3. **CUSTOM IMPLEMENTATION** — Write your script to `codes//scripts/` and register it Always exhaust higher-preference options before falling to lower ones. @@ -75,7 +77,7 @@ check_[X](output) → audit/step_N_post.json All check reports are saved to `audit/` for the audit trail. ### 5. File Organization -- All processing code goes in `codes/scripts/` +- Each registered code is a self-contained dir: spec at `codes//SKILL.md`, its scripts in `codes//scripts/` - All data output goes in a `data/` subdirectory - All audit reports go in `audit/` - All session artifacts stay within the project directory @@ -155,7 +157,7 @@ For each data operation, create TWO codes: - Accepts: input data path, output data path, parameters - Outputs: Transformed data -Write code scripts to `codes/scripts/` and register them via `save_code_spec`. Python dependencies declared in the spec are handled automatically via `uv run --with`. +Write each code's script to `codes//scripts/` and register it via `save_code_spec`. Python dependencies declared in the spec are handled automatically via `uv run --with`. ## PIPELINE RECONSTRUCTION From 9b1fc67cc97162d1024909ab3e187e1a2432acbb Mon Sep 17 00:00:00 2001 From: aarontuor Date: Thu, 2 Jul 2026 14:26:54 -0700 Subject: [PATCH 29/44] =?UTF-8?q?refactor(agents):=20agents=20are=20pre-au?= =?UTF-8?q?thenticated=20=E2=80=94=20remove=20all=20credential=20hint=20ma?= =?UTF-8?q?chinery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the cline decision to every agent: the user configures and authenticates their agent (shell env vars, subscription logins) BEFORE pointing dsagt at it. dsagt neither hints, warns, nor troubleshoots. Removed: credential_env_vars / credential_hints class vars on all five setups, byoa_env_hints(), and the launch-time 'agent will use preconfigured env vars' transparency note (+ their 18 tests). Kept (functional, not troubleshooting): opencode's {env:VAR} provider interpolation in opencode.json, codex's auth.json propagation into the per-project CODEX_HOME, and batch-mode requirements that genuinely need an env value (OPENCODE_MODEL for opencode run -m). Co-Authored-By: Claude Fable 5 --- .env.example | 32 --------- CLAUDE.md | 4 +- developer.md | 41 ------------ src/dsagt/agents/__init__.py | 33 ---------- src/dsagt/agents/base.py | 23 ------- src/dsagt/agents/claude.py | 11 ---- src/dsagt/agents/cline.py | 2 - src/dsagt/agents/codex.py | 6 -- src/dsagt/agents/goose.py | 29 -------- src/dsagt/agents/opencode.py | 33 +--------- tests/test_config.py | 124 ----------------------------------- 11 files changed, 5 insertions(+), 333 deletions(-) delete mode 100644 .env.example delete mode 100644 developer.md diff --git a/.env.example b/.env.example deleted file mode 100644 index 7941a6c..0000000 --- a/.env.example +++ /dev/null @@ -1,32 +0,0 @@ -# Copy to .env and fill in your values. .env is gitignored; do not commit. -# -# Sourced by: -# - tests/smoke_test/run.sh / `dsagt smoke-test` -# - dsagt_config.yaml (per-project) reads these via ${VAR} interpolation -# when init writes the config - -# --- Upstream LLM gateway --- -# LLM_PROVIDER: LiteLLM provider prefix (selects request format + auth). -# Common: openai, anthropic, bedrock, vertex_ai, azure, gemini, -# ollama, mistral, groq, deepseek. -# Full list: https://docs.litellm.ai/docs/providers -LLM_PROVIDER=openai -LLM_API_KEY= -LLM_BASE_URL=https://your-gateway.example.com -LLM_MODEL=claude-sonnet-4-5 - -# --- Embedding endpoint (OPTIONAL) --- -# Embeddings default to ``backend: local`` in dsagt_config.yaml — sentence- -# transformers, CPU-side, no network or API key needed. The variables -# below are only consulted when you flip ``embedding.backend`` to ``api`` -# in a project's dsagt_config.yaml. Leave them blank if you stick with -# the local default. -# -# EMBEDDING_PROVIDER: LiteLLM provider prefix for embeddings. -# ``openai_like`` covers any OpenAI-wire-compatible /embeddings endpoint -# (including most lab gateways). Use ``bedrock`` for native AWS Bedrock -# InvokeModel (Titan, Cohere) or ``openai``, ``cohere``, ``voyage``, etc. -EMBEDDING_PROVIDER=openai_like -EMBEDDING_API_KEY= -EMBEDDING_BASE_URL=https://your-gateway.example.com -EMBEDDING_MODEL=text-embedding-3-small diff --git a/CLAUDE.md b/CLAUDE.md index 6d9efee..ec949dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ uv run ruff check . # lint **`python -m pytest`, not bare `pytest`.** The bare binary picks up the wrong pytest on this machine and crashes with `ModuleNotFoundError: dsagt`. -**Don't run the full suite by default** — ~50s for ~590 tests is too slow for an iteration loop. Run only the test file relevant to the change. `tests/test_config.py` covers session, init, agents, BYOA hints, serverless-store resolution, and the no-launch-shim/no-OTel invariants. Skip `test_integration.py`, `test_*_integration.py`, `test_server_startup.py`, `test_dependency_integration.py` unless relevant — they hit the network or spawn subprocesses. +**Don't run the full suite by default** — ~50s for ~590 tests is too slow for an iteration loop. Run only the test file relevant to the change. `tests/test_config.py` covers session, init, agents, serverless-store resolution, and the no-launch-shim/no-OTel invariants. Skip `test_integration.py`, `test_*_integration.py`, `test_server_startup.py`, `test_dependency_integration.py` unless relevant — they hit the network or spawn subprocesses. ## Code Organization @@ -44,7 +44,7 @@ The codebase separates **commands** (entry points with argparse, launched as CLI **Modules** (`src/dsagt/`): - `session.py` — Project init, agent config generation, env-var resolution, config load/validate, session-id minting (`new_session_id`), end-of-session extraction (`run_extraction`), and startup **catch-up** (`catch_up_extraction`): code-use indexing + a chat-trace re-collect (`_catch_up_traces`) of the *previous* session (pinned to the `trace_source` token in `state.yaml`) so turns lost to an ungraceful shutdown still reach MLflow + episodic memory — uniform across all agents. -- `agents/` — Per-agent-platform setup (`base.py` ABC + `claude.py` / `goose.py` / `cline.py` / `codex.py` / `opencode.py`). Each subclass owns its `write_static`, `write_dynamic`, `runtime_env`, `byoa_env_hints`, `vscode_hint`. Shared helpers (`_mcp_env_block`, `_build_mcp_servers_dict`) in `base.py`. DSAGT sets no telemetry/OTel env and writes no launch shim. +- `agents/` — Per-agent-platform setup (`base.py` ABC + `claude.py` / `goose.py` / `cline.py` / `codex.py` / `opencode.py`). Each subclass owns its `write_static`, `write_dynamic`, `runtime_env`, `vscode_hint`. Shared helpers (`_mcp_env_block`, `_build_mcp_servers_dict`) in `base.py`. DSAGT sets no telemetry/OTel env, writes no launch shim, and never touches provider credentials — agents are expected pre-authenticated (shell env / their own auth flows) before dsagt is pointed at them; dsagt prints no credential hints and never troubleshoots auth. - `knowledge.py` — ChromaDB document retrieval, embedding backends, per-collection routing (the reference example of the house style). - `registry.py` — `CodeRegistry` (CLI codes) + `SkillRegistry` (agent instruction skills), KB indexing. - `provenance.py` — Code execution records (`run_and_record`), execution-record indexing into ChromaDB (`CodeUseIndexer` → `code_use` collection), pipeline reconstruction (`reconstruct_pipeline`, dependency graph). diff --git a/developer.md b/developer.md deleted file mode 100644 index 75a9009..0000000 --- a/developer.md +++ /dev/null @@ -1,41 +0,0 @@ -# DSAgt — Developer Notes - -Material that's useful once you start poking at internals or running beyond the default `dsagt init` → agent flow. - -## Tests - -```bash -uv run python -m pytest -m "not integration" # unit tests, no creds required -uv run python -m pytest -m integration -v # integration tests (require real credentials / models) -``` - -Integration tests need real `EMBEDDING_*` / `LLM_*` credentials (and, for the episodic-memory judge test, the local GGUF model, downloaded on first run). Copy `.env.example` to `.env` and fill in your values where applicable. - -For per-flow hand-tests (CLI, VS Code extensions), see the scripts under [`tests/smoke_test/manual_runs/`](tests/smoke_test/manual_runs/). - -## Run model - -DSAgt is **BYOA (bring your own agent)**: the agent talks to its own LLM provider directly — DSAgt never interposes on that traffic (there is no proxy). Trace capture instead reads the agent's own on-disk session transcript via the MCP server's in-session heartbeat, so no credentials are required and the trace store stays serverless (a SQLite file per project, no daemon). - -## Troubleshooting - -**Agent command not found.** The agent CLI isn't installed or isn't on PATH — see the install table in the [README](README.md#installation). - -**MCP server not connecting.** DSAgt exposes a single server (`dsagt-server`; the earlier `dsagt-registry-server` / `dsagt-knowledge-server` pair was merged in 0.2.0). Verify it resolves: - -```bash -uv run which dsagt-server -``` - -If missing, reinstall: `uv sync --reinstall`. On an existing project created against the old two-server layout, re-run `dsagt start ` to regenerate its MCP config (for cline, delete `/.cline-data` first). - -**No traces / empty MLflow UI.** The store is a serverless SQLite file — there is no daemon. Point the UI at the file and confirm the path: - -```bash -dsagt info # resolved tracking URI + a session/trace summary -mlflow ui --backend-store-uri sqlite:////mlflow.db -``` - -If the file is missing, no session has run in that project yet (the DB is created lazily on the first span). - -**Claude keychain conflict.** If `claude` won't authenticate against a non-default gateway, run `claude /logout` to clear the macOS Keychain OAuth, then re-export `ANTHROPIC_BASE_URL` / `ANTHROPIC_API_KEY` and re-launch. diff --git a/src/dsagt/agents/__init__.py b/src/dsagt/agents/__init__.py index 9641169..94ac45f 100644 --- a/src/dsagt/agents/__init__.py +++ b/src/dsagt/agents/__init__.py @@ -118,42 +118,9 @@ def agent_env(config: dict) -> dict: # user's shell. env.update(setup.runtime_env(config)) - # Transparency: surface what credential env vars the agent will - # actually pick up from the user's shell, so a missing or wrong - # value isn't silently consumed. - _warn_on_preconfigured_creds(setup, env) - return env -def _warn_on_preconfigured_creds(setup: AgentSetup, env: dict) -> None: - """Emit a one-line transparency note listing which of the agent's - credential env vars are present in the user's shell. - - Lists the var NAMES (never values — keys are secrets). Helps the - user verify what the agent will actually pick up. No-op for agents - with no credential env vars declared (IDE-only agents). - """ - if not setup.credential_env_vars: - return - - from_shell = [k for k in setup.credential_env_vars if env.get(k)] - if from_shell: - logger.warning( - "%s: agent will use preconfigured env vars from the shell: %s", - setup.name, - ", ".join(from_shell), - ) - else: - logger.warning( - "%s: none of %s are set in the shell — agent may fall back to " - "its own auth flow (claude.ai subscription, codex login, etc.) " - "or fail at first call.", - setup.name, - ", ".join(setup.credential_env_vars), - ) - - def agent_command(config: dict) -> list[str]: """Return the shell command to launch the agent interactively.""" return _setup_for(config["agent"]).interactive_command(config) diff --git a/src/dsagt/agents/base.py b/src/dsagt/agents/base.py index 43c411a..21c2ef6 100644 --- a/src/dsagt/agents/base.py +++ b/src/dsagt/agents/base.py @@ -296,14 +296,6 @@ class AgentSetup(ABC): static_marker: ClassVar[str] install_hint: ClassVar[str] = "Install the agent CLI first." - #: Env vars the agent's runtime reads for provider credentials / - #: endpoint routing. Used by ``agent_env`` to surface a transparency - #: note about what the agent will pick up from the user's shell: we - #: list which of these are actually present so a missing or wrong - #: value isn't silently consumed. Empty for IDE-extension agents - #: that never read env-var credentials. - credential_env_vars: ClassVar[tuple[str, ...]] = () - #: Directory (relative to the working dir) the agent natively auto-discovers #: ``SKILL.md`` skill folders from. ``setup_skills`` mirrors installed #: (bundled + project) skills AND registered codes here so the agent @@ -410,21 +402,6 @@ def interactive_command(self, config: dict) -> list[str]: del config return list(self.base_command) - #: Per-agent provider-credential hints surfaced by ``byoa_env_hints``. - #: List of ``(env_var_name, hint)`` tuples shown to the user with a - #: "skip if already configured" note. - credential_hints: ClassVar[tuple[tuple[str, str], ...]] = () - - def byoa_env_hints(self) -> list[tuple[str, str]]: - """Provider-credential env vars the user should set in their shell. - - BYOA design: DSAGT writes no agent-affecting env (no OTel routing, - no telemetry flags). The user owns provider credentials — - exported in their shell — and this returns one hint string each so - ``dsagt init`` can remind them what their agent needs. - """ - return list(self.credential_hints) - @abstractmethod def run_script( self, diff --git a/src/dsagt/agents/claude.py b/src/dsagt/agents/claude.py index 0a3640f..f13a44f 100644 --- a/src/dsagt/agents/claude.py +++ b/src/dsagt/agents/claude.py @@ -40,17 +40,6 @@ class ClaudeSetup(AgentSetup): static_marker = "CLAUDE.md" native_skills_dir = ".claude/skills" install_hint = "Install with `npm i -g @anthropic-ai/claude-code`." - # Anthropic-protocol native. - credential_env_vars = ( - "ANTHROPIC_API_KEY", - "ANTHROPIC_BASE_URL", - "ANTHROPIC_MODEL", - ) - credential_hints = ( - ("ANTHROPIC_API_KEY", "your Anthropic API key (skip if subscription-authed)"), - ("ANTHROPIC_BASE_URL", "optional gateway / proxy URL"), - ("ANTHROPIC_MODEL", "optional model override"), - ) def owned_artifacts(self, working_dir: Path) -> list[Path]: return [ diff --git a/src/dsagt/agents/cline.py b/src/dsagt/agents/cline.py index a9f0c51..7a9321a 100644 --- a/src/dsagt/agents/cline.py +++ b/src/dsagt/agents/cline.py @@ -80,8 +80,6 @@ class ClineSetup(AgentSetup): # BEFORE pointing dsagt at it, and dsagt never touches auth state: # running `cline auth` from scavenged env vars can clobber an existing # provider integration (e.g. an OpenAI subscription login). - credential_env_vars = () - credential_hints = () def owned_artifacts(self, working_dir: Path) -> list[Path]: return [ diff --git a/src/dsagt/agents/codex.py b/src/dsagt/agents/codex.py index 599a68a..5c293ab 100644 --- a/src/dsagt/agents/codex.py +++ b/src/dsagt/agents/codex.py @@ -96,12 +96,6 @@ class CodexSetup(AgentSetup): install_hint = ( "Install with `npm i -g @openai/codex` or " "`brew install --cask codex`." ) - # Codex is openai-protocol native. - credential_env_vars = ("OPENAI_API_KEY", "OPENAI_BASE_URL") - credential_hints = ( - ("OPENAI_API_KEY", "your OpenAI API key (skip if subscription-authed)"), - ("OPENAI_BASE_URL", "optional gateway / proxy URL"), - ) def owned_artifacts(self, working_dir: Path) -> list[Path]: return [working_dir / "AGENTS.md", working_dir / ".codex-data"] diff --git a/src/dsagt/agents/goose.py b/src/dsagt/agents/goose.py index c9a36df..1317524 100644 --- a/src/dsagt/agents/goose.py +++ b/src/dsagt/agents/goose.py @@ -45,35 +45,6 @@ class GooseSetup(AgentSetup): static_marker = ".goosehints" native_skills_dir = ".agents/skills" # cross-agent standard goose discovers install_hint = "See https://github.com/block/goose for installation." - # Goose's openai/anthropic providers read ``OPENAI_HOST`` / ``ANTHROPIC_HOST`` - # for the base URL (goose-specific naming), plus its own GOOSE_PROVIDER / - # GOOSE_MODEL routing selectors. - credential_env_vars = ( - "GOOSE_PROVIDER", - "GOOSE_MODEL", - "ANTHROPIC_API_KEY", - "ANTHROPIC_BASE_URL", - "ANTHROPIC_HOST", - "ANTHROPIC_MODEL", - "OPENAI_API_KEY", - "OPENAI_BASE_URL", - "OPENAI_HOST", - ) - credential_hints = ( - ( - "GOOSE_PROVIDER", - "anthropic, openai, etc. (skip if global ~/.config/goose configured)", - ), - ("GOOSE_MODEL", "the model name your provider serves"), - ("ANTHROPIC_API_KEY", "if GOOSE_PROVIDER=anthropic"), - ("ANTHROPIC_HOST", "if GOOSE_PROVIDER=anthropic and on a gateway"), - ("OPENAI_API_KEY", "if GOOSE_PROVIDER=openai"), - ( - "OPENAI_HOST", - "if GOOSE_PROVIDER=openai and on a gateway " - "(NOT OPENAI_BASE_URL — goose ignores that)", - ), - ) def write_static(self, working_dir: Path) -> list[str]: actions: list[str] = [] diff --git a/src/dsagt/agents/opencode.py b/src/dsagt/agents/opencode.py index b010be0..072e552 100644 --- a/src/dsagt/agents/opencode.py +++ b/src/dsagt/agents/opencode.py @@ -118,33 +118,6 @@ class OpenCodeSetup(AgentSetup): install_hint = "Install with `npm i -g opencode-ai`." # The AGENTS.md-convention skills dir codex/goose also use. native_skills_dir = ".agents/skills" - # OpenCode reads provider creds via ``{env:VAR}`` interpolation in - # its config — the file references these vars, opencode resolves - # them from the user's shell at runtime. Same shape as goose's - # multi-protocol story, no on-disk credential leakage. - credential_env_vars = ( - "OPENCODE_MODEL", - "OPENAI_API_KEY", - "OPENAI_BASE_URL", - "ANTHROPIC_API_KEY", - "ANTHROPIC_BASE_URL", - ) - credential_hints = ( - ( - "OPENCODE_MODEL", - "model spec '/' " - "(e.g. 'openai/claude-haiku-4-5-20251001-v1-project' for a PNNL-shape " - "openai gateway, or 'anthropic/claude-sonnet-4-5')", - ), - ("OPENAI_API_KEY", "if your gateway speaks openai wire protocol"), - ( - "OPENAI_BASE_URL", - "openai gateway URL " - "(referenced by opencode.json's provider.openai.options.baseURL)", - ), - ("ANTHROPIC_API_KEY", "if your gateway speaks anthropic wire protocol"), - ("ANTHROPIC_BASE_URL", "anthropic gateway URL"), - ) def owned_artifacts(self, working_dir: Path) -> list[Path]: return [working_dir / "AGENTS.md", working_dir / "opencode.json"] @@ -232,9 +205,9 @@ def run_script( raise RuntimeError( "opencode batch mode requires OPENCODE_MODEL in the shell " "env, formatted as '/' (e.g. " - "'openai/claude-haiku-4-5-20251001-v1-project'). Plus the " - "matching {ANTHROPIC,OPENAI}_API_KEY / _BASE_URL. See " - "agents/opencode.py credential_hints." + "'openai/claude-haiku-4-5-20251001-v1-project'), plus the " + "matching {ANTHROPIC,OPENAI}_API_KEY / _BASE_URL — BYOA: " + "the agent must be pre-configured in the shell." ) cmd = [ "opencode", diff --git a/tests/test_config.py b/tests/test_config.py index 9be898e..7674167 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -882,56 +882,6 @@ def test_no_telemetry_flags_for_claude(self, monkeypatch): assert flag not in env -class TestPreconfiguredCredsWarning: - """When the project YAML has no llm credentials, ``agent_env`` warns - that the agent will fall back to the user's shell env, listing which - of the agent's credential env vars are actually present.""" - - def _config(self, agent: str = "claude", **llm) -> dict: - return { - "project": "test", - "agent": agent, - "project_dir": "/proj", - "mlflow": {"port": 5001}, - "llm": llm, - "embedding": {}, - } - - def test_warns_when_project_has_no_creds_but_shell_does(self, caplog, monkeypatch): - from dsagt.agents import agent_env - - monkeypatch.setenv("ANTHROPIC_API_KEY", "shell-key") - monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://api.anthropic.com") - - with caplog.at_level("WARNING", logger="dsagt.agents"): - agent_env(self._config(agent="claude")) - - msgs = " ".join(r.getMessage() for r in caplog.records) - assert "preconfigured env vars" in msgs - assert "ANTHROPIC_API_KEY" in msgs - assert "ANTHROPIC_BASE_URL" in msgs - # Var names only, never values. - assert "shell-key" not in msgs - - def test_warns_with_no_shell_either_lists_expected_vars(self, caplog, monkeypatch): - """When neither project nor shell has creds, surface the var names - the agent's runtime expects so the user can fix the gap.""" - from dsagt.agents import agent_env - - for var in ("ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_MODEL"): - monkeypatch.delenv(var, raising=False) - - with caplog.at_level("WARNING", logger="dsagt.agents"): - agent_env(self._config(agent="claude")) - - msgs = " ".join(r.getMessage() for r in caplog.records) - assert "fall back to its own auth flow" in msgs - assert "ANTHROPIC_API_KEY" in msgs - # A clean shell must not trigger the preconfigured-creds warning - # (the warning keys off shell env only, never the project YAML). - assert "preconfigured env vars" not in msgs - - # --------------------------------------------------------------------------- # CLI: agent_command # --------------------------------------------------------------------------- @@ -1081,80 +1031,6 @@ def test_mcp_env_block_carries_no_session_id(self): assert "DSAGT_SESSION_ID" not in env2 -# --------------------------------------------------------------------------- -# BYOA: per-agent env hints + launch one-liners surfaced by `dsagt init` -# --------------------------------------------------------------------------- - - -class TestByoaEnvHints: - """``dsagt init`` prints provider credentials only. Internal env - (DSAGT_*, MLFLOW_*, OTEL_*, telemetry verbosity flags) goes into - the per-project launch shim — the user's shell stays clean.""" - - @pytest.mark.parametrize( - "agent_name", ["claude", "goose", "cline", "codex", "opencode"] - ) - def test_returns_only_credential_hints(self, agent_name, tmp_path): - from dsagt.agents import AGENTS - - setup = AGENTS[agent_name]() - hints = setup.byoa_env_hints() - # Only credential hints come back; no DSAGT/MLflow/OTel routing. - names = [n for n, _ in hints] - assert "DSAGT_PROJECT" not in names - assert "MLFLOW_TRACKING_URI" not in names - assert "OTEL_EXPORTER_OTLP_ENDPOINT" not in names - - @pytest.mark.parametrize( - "agent_name", ["claude", "goose", "cline", "codex", "opencode"] - ) - def test_credentials_match_credential_hints(self, agent_name, tmp_path): - from dsagt.agents import AGENTS - - setup = AGENTS[agent_name]() - hints = setup.byoa_env_hints() - assert hints == list(setup.credential_hints) - - def test_goose_credentials_include_host_not_base_url(self, tmp_path): - """Goose's Rust client reads OPENAI_HOST / ANTHROPIC_HOST, NOT - OPENAI_BASE_URL — surfacing this gotcha to the user up front - avoids the silent 'agent hits api.openai.com regardless of - gateway' bug.""" - from dsagt.agents import AGENTS - - names = [n for n, _ in AGENTS["goose"]().byoa_env_hints()] - assert "OPENAI_HOST" in names - assert "ANTHROPIC_HOST" in names - - @pytest.mark.parametrize( - "agent_name,gateway_var", - [ - ("claude", "ANTHROPIC_BASE_URL"), - ("codex", "OPENAI_BASE_URL"), - # opencode emits both — its provider config supports both wire - # protocols via {env:VAR} interpolation in opencode.json. - ("opencode", "OPENAI_BASE_URL"), - ("opencode", "ANTHROPIC_BASE_URL"), - ], - ) - def test_gateway_url_in_credential_hints(self, agent_name, gateway_var, tmp_path): - """Lab gateway / proxy URL hint is surfaced for every agent that - speaks the standard BASE_URL convention.""" - from dsagt.agents import AGENTS - - names = [n for n, _ in AGENTS[agent_name]().byoa_env_hints()] - assert gateway_var in names - - def test_cline_emits_no_credential_hints(self, tmp_path): - """Cline owns its provider auth (subscription login / cline auth / - the VS Code extension) — dsagt surfaces no env-var hints and never - writes cline auth state, since doing so can clobber an existing - provider integration.""" - from dsagt.agents import AGENTS - - assert AGENTS["cline"]().byoa_env_hints() == [] - - class TestNoLaunchShim: """Phase 1 collapsed the launch surface: ``dynamic_agent_record`` writes the MCP config but NO ``dsagt-launch.sh`` shim. The user From 8d17ba90ca727de1d16e39a5b8ee3961fc85c5c3 Mon Sep 17 00:00:00 2001 From: aarontuor Date: Thu, 2 Jul 2026 14:27:38 -0700 Subject: [PATCH 30/44] docs(codes): record why the bundled script lives beside its spec dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit python -m execution is the only machine-independent executable for package-shipped code, and module paths can't contain the hyphen the skill-standard dir name requires — so bundled implementation modules sit beside their spec dirs, unlike project codes whose scripts are self-contained in codes//scripts/. Co-Authored-By: Claude Fable 5 --- src/dsagt/codes/scan_directory.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/dsagt/codes/scan_directory.py b/src/dsagt/codes/scan_directory.py index c4c4ebd..1c4fead 100644 --- a/src/dsagt/codes/scan_directory.py +++ b/src/dsagt/codes/scan_directory.py @@ -2,6 +2,14 @@ """ Scan Directory (macOS + Linux, core-tools implementation) +Lives beside (not inside) its spec dir ``scan-directory/`` on purpose: +the spec's ``executable`` is static text, and a bundled code's install +path varies per machine, so it must run as ``python -m +dsagt.codes.scan_directory`` — and module paths can't contain the +hyphen the skill-standard dir name requires. Project-registered codes +don't have this tension (their scripts live at stable project paths in +``codes//scripts/``). + Sensible defaults: - ALWAYS include hidden files/dirs (dotfiles) - Use core shell tools to enumerate files/sizes (find + stat) and total size (du) From 9c463707a3ccefc7ab3d8ba5016eabc2ae168d4a Mon Sep 17 00:00:00 2001 From: aarontuor Date: Thu, 2 Jul 2026 14:37:36 -0700 Subject: [PATCH 31/44] docs(cline): batch blocker is now MCP-less headless CLI, not the model whitelist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-verified against cline 3.0.34: batch runs fine (act mode, subscription auth honored), but the headless CLI session path never loads MCP servers — a probe session with a registered dsagt entry exposed zero MCP tools. Also verified: cline auth state rides with its config dir, so ANY per-project --config/CLINE_DIR isolation loses a subscription login; subscription users need a single global MCP entry (the server is cwd-self-sufficient). run_script error + smoke SKIP updated to the current rationale; re-probe on cline upgrades. Co-Authored-By: Claude Fable 5 --- src/dsagt/agents/cline.py | 57 ++++++++++++++++++++------------------- tests/smoke_test/run.sh | 11 ++++---- 2 files changed, 35 insertions(+), 33 deletions(-) diff --git a/src/dsagt/agents/cline.py b/src/dsagt/agents/cline.py index 7a9321a..b57f28f 100644 --- a/src/dsagt/agents/cline.py +++ b/src/dsagt/agents/cline.py @@ -9,27 +9,30 @@ by the user before dsagt — dsagt never writes cline auth state, since doing so can clobber an existing provider integration. -**Batch / smoke-test status: NOT SUPPORTED.** Cline's bundled -``lib.mjs`` ships a hardcoded anthropic-provider model whitelist -(``claude-haiku-4-5-20251001``, ``claude-sonnet-4-5-20250929``, etc. — -all without the ``-v1-project`` suffix lab gateways like PNNL require). -Even when ``cline auth -m claude-haiku-4-5-20251001-v1-project`` stores -our model name correctly in ``globalState.json``, at request time -cline's anthropic provider validates against the whitelist, sees the -unrecognized suffixed name, and silently substitutes its hardcoded -default (``claude-sonnet-4-5-20250929``) — which the gateway then 401s -because PNNL only serves the suffixed variant. - -The openai-native path has the same problem (whitelisted ``gpt-5.5``, -``gpt-4o`` etc.; PNNL serves ``-project``-suffixed variants). Bedrock -is locked behind ``cline auth``'s interactive setup flow, not the CLI. -So ``cline.run_script`` raises ``RuntimeError`` and the smoke test -short-circuits in ``tests/smoke_test/run.sh``. +**Batch / smoke-test status: NOT SUPPORTED (re-verified against cline +3.0.34).** Batch itself now works (``cline "prompt"`` runs act-mode +with auto-approve, honoring the user's pre-configured auth — the old +model-whitelist blocker is moot), but the headless CLI session path +never loads MCP servers: a probe session with a registered dsagt entry +exposed zero MCP tools (the ``@cline/core`` MCP machinery exists, but +the hub/headless path doesn't invoke it). Without MCP tools there is +nothing for a smoke run to exercise, so ``cline.run_script`` raises +``RuntimeError`` and the smoke test short-circuits in +``tests/smoke_test/run.sh``. Re-probe on cline upgrades: if a batch +session can call an MCP tool, batch support is one small run_script +away. + +A second structural constraint, verified 3.0.34: cline's auth state +rides with its config directory — ANY per-project ``--config`` / +``CLINE_DIR`` isolation loses a subscription login (Unauthorized). +The per-project MCP config written by ``write_dynamic`` therefore only +works for API-key auth flows; subscription users need the dsagt server +registered in cline's *global* MCP settings (the server is +cwd-self-sufficient, so one global entry serves every project). Interactive use is unaffected — ``dsagt init --agent cline`` still -writes the project state, and users running cline via VS Code (where -the UI exposes ``awsBedrockEndpoint`` etc.) can drive the project -manually. +writes the project state, and users running cline via VS Code can +drive the project manually. OTel support: **none** (verified). Cline ships ``@opentelemetry/*`` packages but installs only a @@ -212,16 +215,14 @@ def run_script( ) -> int: """Not supported for cline — see module docstring. - Cline's bundled anthropic provider hardcodes a model-ID whitelist - that doesn't include lab-gateway aliases (``-v1-project`` etc.), - so the request silently substitutes a default cline thinks it - knows and the gateway 401s. + Cline 3.x batch runs fine (act mode, pre-configured auth), but + its headless CLI never loads MCP servers, so a scripted session + has no dsagt tools to exercise. """ del config, env, working_dir, script_path, max_turns raise RuntimeError( - "cline batch mode is not supported — cline's anthropic " - "provider rewrites unrecognized model names (lab-gateway " - "aliases like ``-v1-project``) to its hardcoded default, " - "which the gateway then rejects. See agents/cline.py module " - "docstring." + "cline batch mode is not supported — cline's headless CLI " + "(verified 3.0.34) does not load MCP servers, so a scripted " + "session has no dsagt tools. See agents/cline.py module " + "docstring; re-probe on cline upgrades." ) diff --git a/tests/smoke_test/run.sh b/tests/smoke_test/run.sh index 4e10864..9b9595a 100755 --- a/tests/smoke_test/run.sh +++ b/tests/smoke_test/run.sh @@ -39,11 +39,12 @@ PDIR="${HOME}/dsagt-projects/${PROJECT}" case "${AGENT}" in goose|claude|codex|opencode) ;; cline) - # dsagt start --script hard-errors for cline (its anthropic provider - # rewrites unrecognized model names, so batch mode is unsupported — - # see agents/cline.py). Skip rather than report 15 red checks; drop - # this arm when that guard is lifted. - echo "[smoke] SKIP: cline batch mode is unsupported (see agents/cline.py) — hand-test via tests/manual_walkthroughs/ instead" + # dsagt start --script hard-errors for cline: its headless CLI + # (verified 3.0.34) never loads MCP servers, so a scripted session + # has no dsagt tools to exercise — see agents/cline.py. Skip rather + # than report red checks; drop this arm when cline ships MCP in + # headless mode. + echo "[smoke] SKIP: cline headless CLI loads no MCP servers (see agents/cline.py) — hand-test via tests/manual_walkthroughs/ instead" exit 0 ;; *) From 7c2100195e5e52c080c5093035fea110f76585a7 Mon Sep 17 00:00:00 2001 From: aarontuor Date: Thu, 2 Jul 2026 14:54:47 -0700 Subject: [PATCH 32/44] =?UTF-8?q?docs(cline):=20correct=20the=20headless-M?= =?UTF-8?q?CP=20finding=20=E2=80=94=20servers=20spawn,=20tools=20never=20b?= =?UTF-8?q?ridge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deeper probes (spawn watcher, MCP-child cwd/env probe, tool-inventory enumeration): headless cline spawns registered MCP servers with cwd = session dir and full shell env, and dsagt-server answers the initialize handshake in ~1.6s — but the session's model toolset contains only cline built-ins + team tools, zero MCP entries. Also pinpointed the auth coupling: providers.json (subscription/OAuth creds) lives inside the settings dir, which is why any --config isolation goes Unauthorized. A global MCP entry is not viable either: it makes every cline session everywhere spawn dsagt-server. Co-Authored-By: Claude Fable 5 --- src/dsagt/agents/cline.py | 39 ++++++++++++++++++++++----------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/src/dsagt/agents/cline.py b/src/dsagt/agents/cline.py index b57f28f..4f3b47c 100644 --- a/src/dsagt/agents/cline.py +++ b/src/dsagt/agents/cline.py @@ -9,26 +9,31 @@ by the user before dsagt — dsagt never writes cline auth state, since doing so can clobber an existing provider integration. -**Batch / smoke-test status: NOT SUPPORTED (re-verified against cline -3.0.34).** Batch itself now works (``cline "prompt"`` runs act-mode -with auto-approve, honoring the user's pre-configured auth — the old -model-whitelist blocker is moot), but the headless CLI session path -never loads MCP servers: a probe session with a registered dsagt entry -exposed zero MCP tools (the ``@cline/core`` MCP machinery exists, but -the hub/headless path doesn't invoke it). Without MCP tools there is -nothing for a smoke run to exercise, so ``cline.run_script`` raises -``RuntimeError`` and the smoke test short-circuits in -``tests/smoke_test/run.sh``. Re-probe on cline upgrades: if a batch +**Batch / smoke-test status: NOT SUPPORTED (verified against cline +3.0.34).** Batch itself works (``cline "prompt"`` runs act-mode with +auto-approve, honoring the user's pre-configured auth — the old +model-whitelist blocker is moot), and headless cline even SPAWNS +registered MCP servers correctly (cwd = the session dir, full shell +env — verified with a spawn probe). But it never bridges their tools +into the model's toolset: a session asked to enumerate every tool it +has lists only cline built-ins + team tools, zero MCP entries. With +no dsagt tools reachable there is nothing for a smoke run to exercise, +so ``cline.run_script`` raises ``RuntimeError`` and the smoke test +short-circuits in ``tests/smoke_test/run.sh``. Re-probe on cline +upgrades (the TUI / VS Code paths do bridge MCP): if a headless session can call an MCP tool, batch support is one small run_script away. -A second structural constraint, verified 3.0.34: cline's auth state -rides with its config directory — ANY per-project ``--config`` / -``CLINE_DIR`` isolation loses a subscription login (Unauthorized). -The per-project MCP config written by ``write_dynamic`` therefore only -works for API-key auth flows; subscription users need the dsagt server -registered in cline's *global* MCP settings (the server is -cwd-self-sufficient, so one global entry serves every project). +Auth constraint, verified 3.0.34: cline stores provider credentials in +``providers.json`` INSIDE its settings/config directory, so ANY +per-project ``--config`` / ``CLINE_DIR`` isolation loses a +subscription/OAuth login (instant Unauthorized). The per-project MCP +config written by ``write_dynamic`` therefore only works for flows +that re-auth into the project dir; subscription users would need the +dsagt server in cline's *global* MCP settings — but a global entry +makes EVERY cline session (any directory) spawn dsagt-server, so it +must not be wired unless the server no-ops gracefully outside a +project. Interactive use is unaffected — ``dsagt init --agent cline`` still writes the project state, and users running cline via VS Code can From 3cc3002573cb139d08f908085152e37631d10dca Mon Sep 17 00:00:00 2001 From: aarontuor Date: Thu, 2 Jul 2026 15:05:06 -0700 Subject: [PATCH 33/44] =?UTF-8?q?feat(registry):=20bundled=20codes=20copie?= =?UTF-8?q?d=20into=20/codes/=20=E2=80=94=20one=20place,=20one=20?= =?UTF-8?q?format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parity: every available code — bundled or agent-registered — now lives in /codes// as a self-contained skill-standard dir. ensure_bundled_copies() runs at dsagt init (never clobbers an existing dir; re-init after upgrade refreshes untouched copies). This dissolves the bundled/project two-layer merge in CodeRegistry (single glob, no source_tools_dir seam) and the scan_directory.py placement asymmetry: the script moves inside scan-directory/scripts/ and the executable becomes an ordinary project-relative path instead of python -m. reindex_all removed (no production caller). Verified: claude smoke 18/18 with scan-directory executing via the project-relative path. Unit suite: 607 passed. Co-Authored-By: Claude Fable 5 --- pyproject.toml | 2 +- src/dsagt/codes/scan-directory/SKILL.md | 2 +- .../scripts}/scan_directory.py | 8 -- src/dsagt/dsagt_instructions.md | 2 +- src/dsagt/mcp/server.py | 1 - src/dsagt/registry.py | 115 ++++++------------ src/dsagt/session.py | 9 +- tests/test_config.py | 7 +- tests/test_dependency_integration.py | 1 - tests/test_dsagt_server.py | 3 +- tests/test_observability.py | 7 +- tests/test_registry.py | 79 ++++++------ tests/test_registry_server.py | 38 +----- tests/test_site_config.yaml | 26 ++++ 14 files changed, 129 insertions(+), 171 deletions(-) rename src/dsagt/codes/{ => scan-directory/scripts}/scan_directory.py (94%) create mode 100755 tests/test_site_config.yaml diff --git a/pyproject.toml b/pyproject.toml index a2fb2f5..99e81fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,7 +62,7 @@ version = {attr = "dsagt.__version__"} where = ["src"] [tool.setuptools.package-data] -dsagt = ["py.typed", "codes/**/*.md", "codes/*.py", "skills/**/*", "dsagt_instructions.md"] +dsagt = ["py.typed", "codes/**/*", "skills/**/*", "dsagt_instructions.md"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/src/dsagt/codes/scan-directory/SKILL.md b/src/dsagt/codes/scan-directory/SKILL.md index 8a78fd4..b519101 100644 --- a/src/dsagt/codes/scan-directory/SKILL.md +++ b/src/dsagt/codes/scan-directory/SKILL.md @@ -2,7 +2,7 @@ name: scan-directory description: Scan a data directory and produce structured report with file counts, sizes, and directory tree -executable: dsagt-run --code scan-directory -- python -m dsagt.codes.scan_directory +executable: dsagt-run --code scan-directory -- python codes/scan-directory/scripts/scan_directory.py parameters: directory: type: string diff --git a/src/dsagt/codes/scan_directory.py b/src/dsagt/codes/scan-directory/scripts/scan_directory.py similarity index 94% rename from src/dsagt/codes/scan_directory.py rename to src/dsagt/codes/scan-directory/scripts/scan_directory.py index 1c4fead..c4c4ebd 100644 --- a/src/dsagt/codes/scan_directory.py +++ b/src/dsagt/codes/scan-directory/scripts/scan_directory.py @@ -2,14 +2,6 @@ """ Scan Directory (macOS + Linux, core-tools implementation) -Lives beside (not inside) its spec dir ``scan-directory/`` on purpose: -the spec's ``executable`` is static text, and a bundled code's install -path varies per machine, so it must run as ``python -m -dsagt.codes.scan_directory`` — and module paths can't contain the -hyphen the skill-standard dir name requires. Project-registered codes -don't have this tension (their scripts live at stable project paths in -``codes//scripts/``). - Sensible defaults: - ALWAYS include hidden files/dirs (dotfiles) - Use core shell tools to enumerate files/sizes (find + stat) and total size (du) diff --git a/src/dsagt/dsagt_instructions.md b/src/dsagt/dsagt_instructions.md index 1b4a782..0208c23 100644 --- a/src/dsagt/dsagt_instructions.md +++ b/src/dsagt/dsagt_instructions.md @@ -15,7 +15,7 @@ All data operations must be performed by calling registered codes. The point is **Whenever the user says "what do you remember", "recall", or asks you to retrieve a previously-stored fact, you MUST call `kb_get_memories()` first** and answer based on its result, not from in-context message history. ### 1b. Registered-Code Invocation: Use the `executable` String Verbatim -**When invoking a registered code, copy the spec's `executable` field byte-for-byte, including any `dsagt-run --code --` prefix.** The prefix is the wrapper that writes the execution record to `trace_archive/`; bypassing it (e.g. running the bare script directly when the spec says `dsagt-run --code scan-directory -- python -m dsagt.codes.scan_directory`) loses provenance and breaks pipeline reconstruction. If `dsagt-run` errors with "command not found", surface the error rather than working around it. +**When invoking a registered code, copy the spec's `executable` field byte-for-byte, including any `dsagt-run --code --` prefix.** The prefix is the wrapper that writes the execution record to `trace_archive/`; bypassing it (e.g. running the bare script directly when the spec says `dsagt-run --code scan-directory -- python codes/scan-directory/scripts/scan_directory.py`) loses provenance and breaks pipeline reconstruction. If `dsagt-run` errors with "command not found", surface the error rather than working around it. ### 2. Code and Skill Discovery diff --git a/src/dsagt/mcp/server.py b/src/dsagt/mcp/server.py index e36eac0..0f1f477 100644 --- a/src/dsagt/mcp/server.py +++ b/src/dsagt/mcp/server.py @@ -436,7 +436,6 @@ def main(): # bundled embedding work happens here; save_code_spec incurs a single # embed at save time. registry = CodeRegistry( - source_tools_dir=None, runtime_dir=str(project_dir), kb=kb, ) diff --git a/src/dsagt/registry.py b/src/dsagt/registry.py index 42541d4..2278e6a 100644 --- a/src/dsagt/registry.py +++ b/src/dsagt/registry.py @@ -35,6 +35,7 @@ import logging import re +import shutil from pathlib import Path from typing import TYPE_CHECKING @@ -309,21 +310,14 @@ class CodeRegistry: """ Manages CLI code spec files and optional KB indexing. - Two layers: - - * **Bundled codes** ship with the dsagt package at - ``_PACKAGE_CODES_DIR``. They are read-only; their KB embeddings - live in the shared ``bundled_tools`` collection (built once per - machine per dsagt version by ``dsagt init``). Never copied - into projects, so package upgrades automatically reach all - existing projects. - * **Project codes** are agent-saved or user-edited specs in - ``/codes/``. Embeddings go into the project-local - ``registered_tools`` collection on save. - - Listing / lookup methods merge both layers (project wins on name - collision so agents can override a bundled code). Search the - KB-side via ``search_registry`` which queries both collections. + One layer: every code — bundled or agent-registered — is a + skill-standard directory in ``/codes//``. Bundled + codes ship with the package at ``_PACKAGE_CODES_DIR`` and are COPIED + into the project at ``dsagt init`` (:meth:`ensure_bundled_copies`), + so all available codes live in one place, in one format, fully + self-contained (spec + scripts). Re-running ``dsagt init`` after a + package upgrade refreshes unmodified copies; a user-edited copy is + never clobbered. KB-side search via ``search_registry``. """ _PACKAGE_CODES_DIR = Path(__file__).parent / "codes" @@ -331,65 +325,53 @@ class CodeRegistry: def __init__( self, runtime_dir: str | Path, - source_tools_dir: str | None = None, kb: KnowledgeBase | None = None, ): self.runtime_dir = Path(runtime_dir) self.codes_dir = self.runtime_dir / "codes" self._kb = kb self.runtime_dir.mkdir(parents=True, exist_ok=True) - # Optional override of the package-bundled directory (used by - # tests; production callers leave source_tools_dir=None and let - # the package dir stand). - self._bundled_dir = ( - Path(source_tools_dir) - if source_tools_dir and Path(source_tools_dir).exists() - else self._PACKAGE_CODES_DIR - ) - - # Project code dir is always agent-writable. We no longer - # pre-populate it with bundled codes — they're served directly - # from the package via the merge in list_codes / get_code. # Each code is a self-contained skill-standard directory # (``codes//SKILL.md`` + optional ``scripts/``), so there is # no shared scripts/ dir to pre-create. self.codes_dir.mkdir(parents=True, exist_ok=True) - def _bundled_code_paths(self) -> list[Path]: - """Return SKILL.md spec paths shipped with the package.""" - if not self._bundled_dir.exists(): - return [] - return sorted(self._bundled_dir.glob("*/SKILL.md")) + def ensure_bundled_copies(self) -> list[str]: + """Copy package-bundled code dirs into ``/codes/``. + + Called at ``dsagt init``. A dir whose name already exists in the + project is left untouched — user edits and agent overrides win; + delete the dir and re-init to restore the packaged version. + Returns one action line per copy made. + """ + actions: list[str] = [] + if not self._PACKAGE_CODES_DIR.exists(): + return actions + for spec in sorted(self._PACKAGE_CODES_DIR.glob("*/SKILL.md")): + dest = self.codes_dir / spec.parent.name + if dest.exists(): + continue + shutil.copytree(spec.parent, dest) + actions.append(f"Copied bundled code {spec.parent.name} into {dest}") + return actions def _project_code_paths(self) -> list[Path]: - """Return SKILL.md spec paths the agent has saved into this project.""" + """Return SKILL.md spec paths in this project's codes dir.""" return sorted(self.codes_dir.glob("*/SKILL.md")) def code_dirs(self) -> list[Path]: - """All code directories, bundled first so project wins downstream - name collisions (mirror order — see ``AgentSetup.setup_skills``).""" - return [p.parent for p in self._bundled_code_paths()] + [ - p.parent for p in self._project_code_paths() - ] + """All code directories (for the native-skills mirror — see + ``AgentSetup.setup_skills``).""" + return [p.parent for p in self._project_code_paths()] def list_codes_raw(self) -> list[dict]: - """Return full frontmatter dicts for all codes. - - Merges bundled (package) + project (``/codes/``). - Project codes win on name collision so agents can override a - bundled code with their own implementation. - """ + """Return full frontmatter dicts for all codes in the project.""" seen: dict[str, dict] = {} - for p in self._bundled_code_paths(): - spec = _parse_frontmatter(p) - name = spec.get("name") - if name: - seen[name] = spec for p in self._project_code_paths(): spec = _parse_frontmatter(p) name = spec.get("name") if name: - seen[name] = spec # project layer overrides bundled + seen[name] = spec return [seen[name] for name in sorted(seen)] def list_codes(self) -> list[dict]: @@ -423,15 +405,10 @@ def list_codes(self) -> list[dict]: return codes def get_code(self, name: str) -> dict | None: - """Look up a code spec by name. Project layer overrides bundled.""" - project_path = self.codes_dir / name / "SKILL.md" - if project_path.exists(): - code = _parse_frontmatter(project_path) - if code.get("name") == name: - return code - bundled_path = self._bundled_dir / name / "SKILL.md" - if bundled_path.exists(): - code = _parse_frontmatter(bundled_path) + """Look up a code spec by name.""" + path = self.codes_dir / name / "SKILL.md" + if path.exists(): + code = _parse_frontmatter(path) if code.get("name") == name: return code return None @@ -506,24 +483,6 @@ def _index_code(self, spec: dict, tool_path: Path) -> None: metadatas=[metadata], ) - def reindex_all(self) -> int: - """Reindex project-local code files into the ``codes`` collection. - - Returns count indexed. Bundled codes are NOT indexed here — they - live in the shared ``codes`` collection built and copied into the - project at ``dsagt init`` time. Search via - ``search_registry`` queries the merged collection. - """ - if not self._kb: - return 0 - count = 0 - for path in self._project_code_paths(): - spec = _parse_frontmatter(path) - if spec.get("name"): - self._index_code(spec, path) - count += 1 - return count - # --------------------------------------------------------------------------- # Skill Registry diff --git a/src/dsagt/session.py b/src/dsagt/session.py index 19bb871..4b9a634 100644 --- a/src/dsagt/session.py +++ b/src/dsagt/session.py @@ -620,11 +620,18 @@ def init_project( pdir = (location or DEFAULT_PROJECTS_BASE) / project_name pdir.mkdir(parents=True, exist_ok=True) - # `codes/` is created by CodeRegistry on first server startup. # ``mlflow.db`` is created lazily by the MLflow client on first span. for subdir in ("trace_archive", "skills", CONFIG_DIRNAME): (pdir / subdir).mkdir(parents=True, exist_ok=True) + # Bundled codes are copied into /codes/ so every available + # code lives in one place, in one format (skill-standard dirs), fully + # self-contained. Re-init after a package upgrade refreshes copies + # the user hasn't touched; edited/overridden dirs are never clobbered. + from dsagt.registry import CodeRegistry + + CodeRegistry(runtime_dir=pdir).ensure_bundled_copies() + _provision_kb(pdir, include, exclude, embedding=embedding) write_config_file( diff --git a/tests/test_config.py b/tests/test_config.py index 7674167..1b54682 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -376,9 +376,10 @@ def test_creates_directory_structure(self): assert (pdir / "skills").is_dir() assert (pdir / "kb_index").is_dir() assert (pdir / ".dsagt").is_dir() - # `tools/` is intentionally NOT created by init_project — CodeRegistry - # creates it on first server startup so bundled tools get copied in. - assert not (pdir / "codes").exists() + # Bundled codes are copied into codes/ at init — every available + # code in one place, one format (skill-standard dirs). + assert (pdir / "codes" / "scan-directory" / "SKILL.md").exists() + assert (pdir / "codes" / "scan-directory" / "scripts").is_dir() # Serverless: no MLflow store is pre-created; ``mlflow.db`` is # written lazily by the MLflow client on first span. assert not (pdir / "mlflow.db").exists() diff --git a/tests/test_dependency_integration.py b/tests/test_dependency_integration.py index d17cbcc..53c4251 100644 --- a/tests/test_dependency_integration.py +++ b/tests/test_dependency_integration.py @@ -94,7 +94,6 @@ def test_register_and_run_tool_with_dependency(tmp_path): # 2. Create a registry server with a fresh CodeRegistry registry = CodeRegistry( - source_tools_dir=None, runtime_dir=str(tmp_path / "runtime"), ) server = create_registry_server(registry) diff --git a/tests/test_dsagt_server.py b/tests/test_dsagt_server.py index a125fdf..0174744 100644 --- a/tests/test_dsagt_server.py +++ b/tests/test_dsagt_server.py @@ -28,7 +28,8 @@ def _make_merged_server(tmp_path: Path): kb.default_rerank = True kb.collections = [] runtime = str(tmp_path / "runtime") - reg = CodeRegistry(source_tools_dir=None, runtime_dir=runtime, kb=None) + reg = CodeRegistry(runtime_dir=runtime, kb=None) + reg.ensure_bundled_copies() sreg = SkillRegistry(source_skills_dir=None, runtime_dir=runtime, kb=None) return create_dsagt_server(reg, kb, sreg, runtime_dir=runtime) diff --git a/tests/test_observability.py b/tests/test_observability.py index 0a3e9ca..2809ac6 100644 --- a/tests/test_observability.py +++ b/tests/test_observability.py @@ -579,12 +579,7 @@ def _make_registry_server(tmp_path): from dsagt.mcp.registry_tools import create_registry_server from dsagt.registry import CodeRegistry - source_dir = tmp_path / "source_skills" - source_dir.mkdir() - reg = CodeRegistry( - source_tools_dir=str(source_dir), - runtime_dir=str(tmp_path / "runtime"), - ) + reg = CodeRegistry(runtime_dir=str(tmp_path / "runtime")) return create_registry_server(reg) diff --git a/tests/test_registry.py b/tests/test_registry.py index 4981614..ace3af3 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -101,14 +101,12 @@ def _write_tool(codes_dir, spec: dict) -> None: def make_registry(tmp_path, tools: list[dict]) -> CodeRegistry: """Create a CodeRegistry with the given tool definitions.""" - codes_dir = tmp_path / "source_skills" - codes_dir.mkdir() + runtime_dir = tmp_path / "runtime" + codes_dir = runtime_dir / "codes" + codes_dir.mkdir(parents=True, exist_ok=True) for tool in tools: _write_tool(codes_dir, tool) - return CodeRegistry( - source_tools_dir=str(codes_dir), - runtime_dir=str(tmp_path / "runtime"), - ) + return CodeRegistry(runtime_dir=str(runtime_dir)) @pytest.fixture @@ -312,30 +310,41 @@ def test_update_overwrites_frontmatter(self, empty_registry): # --------------------------------------------------------------------------- -class TestRuntimeIsolation: - - def test_source_unchanged_after_init(self, tmp_path): - """Source skills directory is not modified; runtime copy is separate.""" - source_dir = tmp_path / "source_skills" - source_dir.mkdir() - _write_tool(source_dir, TOOL_NO_PARAMS) - - runtime_dir = tmp_path / "runtime" - reg = CodeRegistry( - source_tools_dir=str(source_dir), - runtime_dir=str(runtime_dir), +class TestBundledCopies: + + def test_copies_bundled_codes_into_project(self, tmp_path): + """ensure_bundled_copies lands each packaged code dir in codes/.""" + reg = CodeRegistry(runtime_dir=str(tmp_path / "rt")) + actions = reg.ensure_bundled_copies() + assert any("scan-directory" in a for a in actions) + copied = tmp_path / "rt" / "codes" / "scan-directory" + assert (copied / "SKILL.md").exists() + # Fully self-contained: the implementation script rides along. + assert (copied / "scripts" / "scan_directory.py").exists() + assert reg.get_code("scan-directory") is not None + + def test_never_clobbers_existing_copy(self, tmp_path): + """A user-edited (or agent-overridden) code dir is left untouched.""" + reg = CodeRegistry(runtime_dir=str(tmp_path / "rt")) + reg.ensure_bundled_copies() + spec = tmp_path / "rt" / "codes" / "scan-directory" / "SKILL.md" + spec.write_text(spec.read_text() + "\nUser edit.\n") + actions = reg.ensure_bundled_copies() + assert actions == [] + assert "User edit." in spec.read_text() + + def test_package_source_unmodified(self, tmp_path): + """Copying never touches the packaged source dirs.""" + before = sorted( + p.relative_to(CodeRegistry._PACKAGE_CODES_DIR) + for p in CodeRegistry._PACKAGE_CODES_DIR.rglob("*") ) - - # Add a tool to runtime - reg.save_tool(TOOL_WITH_MIXED_PARAMS) - - # Source should be unchanged - source_files = list(source_dir.glob("*/SKILL.md")) - assert len(source_files) == 1 - assert source_files[0].parent.name == "ping" - - # Runtime should have both tools - assert len(reg.list_codes()) == 2 + CodeRegistry(runtime_dir=str(tmp_path / "rt")).ensure_bundled_copies() + after = sorted( + p.relative_to(CodeRegistry._PACKAGE_CODES_DIR) + for p in CodeRegistry._PACKAGE_CODES_DIR.rglob("*") + ) + assert before == after # --------------------------------------------------------------------------- @@ -363,12 +372,12 @@ def test_tools_are_valid(self): assert tool.get("executable"), f"{path.name}: missing 'executable'" assert "parameters" in tool, f"{path.name}: missing 'parameters'" - def test_default_init_fallback(self, tmp_path): - """CodeRegistry with no source_tools_dir falls back to package skills.""" - reg = CodeRegistry(source_tools_dir=None, runtime_dir=str(tmp_path / "rt")) - tools = reg.list_codes() - assert len(tools) > 0 - names = [t["name"] for t in tools] + def test_bundled_codes_appear_after_copy(self, tmp_path): + """A fresh registry is empty until ensure_bundled_copies runs.""" + reg = CodeRegistry(runtime_dir=str(tmp_path / "rt")) + assert reg.list_codes() == [] + reg.ensure_bundled_copies() + names = [t["name"] for t in reg.list_codes()] assert "scan-directory" in names diff --git a/tests/test_registry_server.py b/tests/test_registry_server.py index f5a0799..cc9ef41 100644 --- a/tests/test_registry_server.py +++ b/tests/test_registry_server.py @@ -53,23 +53,15 @@ def _write_tool(codes_dir: Path, spec: dict) -> None: def _make_server(tmp_path, tools=None): """Create (server, registry) with optional pre-populated tools. - Pre-populated tools are written into ``/tools/`` (the - project layer) so they exercise the agent-saved code path — - ``reindex_all`` and most lookups happen here. The ``source_dir`` - is still passed as the bundled layer override but left empty; - tests that need bundled-layer behavior populate it explicitly. + Pre-populated tools are written into ``/codes/`` — the + single project layer every lookup reads. """ - source_dir = tmp_path / "source_skills" - source_dir.mkdir() runtime_dir = tmp_path / "runtime" project_tools_dir = runtime_dir / "codes" project_tools_dir.mkdir(parents=True, exist_ok=True) for spec in tools or []: _write_tool(project_tools_dir, spec) - reg = CodeRegistry( - source_tools_dir=str(source_dir), - runtime_dir=str(runtime_dir), - ) + reg = CodeRegistry(runtime_dir=str(runtime_dir)) return create_registry_server(reg), reg @@ -535,12 +527,10 @@ def _make_server_with_kb(tmp_path, tools=None): """Create (server, registry, kb) with a real local-embedding KnowledgeBase. Pre-populated tools are written to ``/tools/`` so they - exercise the agent-saved code path that ``reindex_all`` operates on. + exercise the agent-saved code path. """ from dsagt.knowledge import KnowledgeBase - source_dir = tmp_path / "source_skills" - source_dir.mkdir() runtime_dir = tmp_path / "runtime" project_tools_dir = runtime_dir / "codes" project_tools_dir.mkdir(parents=True, exist_ok=True) @@ -552,7 +542,6 @@ def _make_server_with_kb(tmp_path, tools=None): default_embedder="local", ) reg = CodeRegistry( - source_tools_dir=str(source_dir), runtime_dir=str(runtime_dir), kb=kb, ) @@ -619,22 +608,3 @@ def test_search_registry_by_tag(self, tmp_path): server, "search_registry", {"query": "tool", "tag": "genomics"} ) assert "fastp" in text - - def test_reindex_all(self, tmp_path): - """reindex_all populates KB from existing skill files.""" - from dsagt.registry import TOOL_REGISTRY_COLLECTION - - server, reg, kb = _make_server_with_kb( - tmp_path, - tools=[ - make_spec(name="preexisting", description="Already registered tool") - ], - ) - - # Skills were copied to runtime on init but not indexed (KB was empty) - # reindex_all should pick them up - count = reg.reindex_all() - assert count >= 1 - - results = kb.search("registered", collection=TOOL_REGISTRY_COLLECTION) - assert len(results) > 0 diff --git a/tests/test_site_config.yaml b/tests/test_site_config.yaml new file mode 100755 index 0000000..b53f049 --- /dev/null +++ b/tests/test_site_config.yaml @@ -0,0 +1,26 @@ +# Test site configuration — institution-specific values for integration tests. +# +# Copy this file to tests/test_site_config.yaml and fill in your values. +# Unit tests (mocked) run without this file. Integration tests that hit +# a real embedding API will skip if this file is missing. +# +# DO NOT commit test_site_config.yaml — it contains API endpoints and +# model names specific to your institution. + +project: test-integration +agent: claude + +llm: + model: claude-sonnet-4-5-20250929-v1-project # LLM model name at your endpoint + base_url: https://api.example.com # LLM API endpoint (Anthropic-compatible) + api_key: sk-QKnGITknkwJbBjhaPcj4QQ # API key for LLM service + +embedding: + model: text-embedding-3-small-project # embedding model at your endpoint + base_url: https://ai-incubator-api.pnnl.gov # OpenAI-compatible embeddings URL + api_key: sk-QKnGITknkwJbBjhaPcj4QQ # API key for embedding service + +mlflow: + port: 15001 # test MLflow port + backend: sqlite + From ece3a950f5ba2c79a52494e0393616a3d8073416 Mon Sep 17 00:00:00 2001 From: aarontuor Date: Thu, 2 Jul 2026 15:09:28 -0700 Subject: [PATCH 34/44] =?UTF-8?q?feat(cline):=20per-project=20MCP=20config?= =?UTF-8?q?=20via=20CLINE=5FMCP=5FSETTINGS=5FPATH=20=E2=80=94=20global=20a?= =?UTF-8?q?uth=20+=20settings=20untouched?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cline 3.x resolves its MCP settings file and providers.json (auth) through INDEPENDENT env overrides. Setting CLINE_MCP_SETTINGS_PATH to /.cline-data/cline_mcp_settings.json in runtime_env gives all three at once (verified live): per-project dsagt server spawns, the user's subscription/codex auth keeps working, and the global mcpServers list stays empty. write_dynamic now hand-writes that file directly (the 'only cline mcp add is loaded' behavior is gone in 3.x; verified a hand-written file loads) with the env block in transport.env — no cline binary needed at init, no add+patch dance, preserves user-added entries. The auth-clobbering CLINE_DIR runtime isolation is dropped. Co-Authored-By: Claude Fable 5 --- src/dsagt/agents/cline.py | 160 ++++++++++++++++---------------------- tests/test_config.py | 58 +++++++++++--- 2 files changed, 112 insertions(+), 106 deletions(-) diff --git a/src/dsagt/agents/cline.py b/src/dsagt/agents/cline.py index 4f3b47c..2bdff83 100644 --- a/src/dsagt/agents/cline.py +++ b/src/dsagt/agents/cline.py @@ -2,12 +2,13 @@ Cline agent setup. Install: ``npm i -g cline``. -Generates: ``.clinerules/dsagt_instructions.md``; -``cline mcp add`` runs per-init in :meth:`ClineSetup.write_dynamic` and -writes to ``$CLINE_DIR/data/``. Provider auth is cline's own -(subscription login / ``cline auth`` / the VS Code extension), configured -by the user before dsagt — dsagt never writes cline auth state, since -doing so can clobber an existing provider integration. +Generates: ``.clinerules/dsagt_instructions.md`` and +``.cline-data/cline_mcp_settings.json`` (hand-written per-project MCP +config, loaded via the ``CLINE_MCP_SETTINGS_PATH`` env var at ``dsagt +start``). Provider auth is cline's own (subscription login / ``cline +auth`` / the VS Code extension), configured by the user before dsagt — +dsagt never writes cline auth state, since doing so can clobber an +existing provider integration. **Batch / smoke-test status: NOT SUPPORTED (verified against cline 3.0.34).** Batch itself works (``cline "prompt"`` runs act-mode with @@ -24,16 +25,17 @@ session can call an MCP tool, batch support is one small run_script away. -Auth constraint, verified 3.0.34: cline stores provider credentials in -``providers.json`` INSIDE its settings/config directory, so ANY -per-project ``--config`` / ``CLINE_DIR`` isolation loses a -subscription/OAuth login (instant Unauthorized). The per-project MCP -config written by ``write_dynamic`` therefore only works for flows -that re-auth into the project dir; subscription users would need the -dsagt server in cline's *global* MCP settings — but a global entry -makes EVERY cline session (any directory) spawn dsagt-server, so it -must not be wired unless the server no-ops gracefully outside a -project. +Auth + config scoping, verified 3.0.34: cline stores provider +credentials in ``providers.json`` beside its MCP settings, and BOTH +follow a ``--config`` / ``CLINE_DIR`` redirect — so whole-directory +isolation loses a subscription/OAuth login (instant Unauthorized). +The escape hatch is ``CLINE_MCP_SETTINGS_PATH``: it relocates ONLY the +MCP settings file (``providers.json`` resolves independently via its +own ``CLINE_PROVIDER_SETTINGS_PATH`` / data-dir default). dsagt sets +it in :meth:`ClineSetup.runtime_env`, giving per-project dsagt MCP +config + the user's global auth + zero footprint in the global +settings, all at once (verified: per-project server spawns, +subscription answered, global mcpServers unchanged). Interactive use is unaffected — ``dsagt init --agent cline`` still writes the project state, and users running cline via VS Code can @@ -50,18 +52,18 @@ extraction will see no agent-conversation traces; tool execution and KB observability still work via dsagt-run / MCP-server spans. -MCP config: hand-writing ``cline_mcp_settings.json`` is silently ignored; -the only path cline loads is via ``cline mcp add``, which writes a -stripped schema (no ``env`` block). We patch in the env block ourselves -after ``cline mcp add`` runs so the dsagt MCP-server children inherit -``MLFLOW_TRACKING_URI``, ``EMBEDDING_*``, ``OTEL_*`` etc. +MCP config: cline 3.x loads whatever file ``CLINE_MCP_SETTINGS_PATH`` +names (hand-written files work — the old "only ``cline mcp add`` is +loaded" behavior is gone), with the schema +``{"mcpServers": {: {"transport": {type, command, args, env}}}}``. +The env block rides in ``transport.env`` so dsagt MCP-server children +get ``MLFLOW_TRACKING_URI``, ``DSAGT_PROJECT_DIR``, ``EMBEDDING_*``. """ from __future__ import annotations import json import logging -import subprocess from pathlib import Path from .base import ( @@ -119,95 +121,63 @@ def write_dynamic( working_dir: Path, pdir: Path, ) -> list[str]: - """Two side-effects, both idempotent: + """Write ``/.cline-data/cline_mcp_settings.json`` directly. - 1. ``cline mcp add`` per server (skipped if entry already exists - — cline's mcp subcommand has no remove, only add). - 2. Patch the JSON cline wrote with our env block (cline doesn't - inherit parent env into MCP children, so MLFLOW_TRACKING_URI, - DSAGT_PROJECT_DIR, EMBEDDING_* must live in the JSON). + Cline 3.x resolves its MCP settings file via the + ``CLINE_MCP_SETTINGS_PATH`` env var (set by :meth:`runtime_env`), + independent of ``providers.json`` (auth) — so a hand-written + per-project file gives project-scoped dsagt MCP config while the + user's global auth and global settings stay untouched (verified + 3.0.34: per-project server spawns, subscription auth honored, + global mcpServers list unchanged). The env block rides inside + ``transport.env`` per cline's ``McpStdioTransportConfig`` schema. Provider auth is cline's own (subscription login / ``cline auth`` / the VS Code extension) and must be configured before using dsagt — dsagt never writes cline auth state (see the class comment). - Requires the ``cline`` binary to be installed at init time. + Idempotent; preserves any non-dsagt entries the user added to the + per-project file. No cline binary needed at init. """ del env, pdir - actions: list[str] = [] - cline_dir = str(working_dir / ".cline-data") - Path(cline_dir).mkdir(parents=True, exist_ok=True) - - # Cline's mcp subcommand only has ``add`` (no ``remove``), and ``add`` - # errors if the server already exists. Detect pre-existing entries - # and skip the add so this method is idempotent (called by both - # ``dsagt init`` and ``_cmd_start`` → ``dynamic_agent_record``). - # The env-block patch below runs unconditionally to keep on-disk - # routing in sync if mlflow port / paths ever changed. - mcp_path = Path(cline_dir) / "data" / "settings" / "cline_mcp_settings.json" - existing: set[str] = set() - if mcp_path.exists(): + settings_path = working_dir / ".cline-data" / "cline_mcp_settings.json" + settings_path.parent.mkdir(parents=True, exist_ok=True) + + settings: dict = {"mcpServers": {}} + if settings_path.exists(): try: - existing = set( - json.loads(mcp_path.read_text()).get("mcpServers", {}).keys() - ) + settings = json.loads(settings_path.read_text()) + settings.setdefault("mcpServers", {}) except (json.JSONDecodeError, OSError): - existing = set() - - if "dsagt" not in existing: - # ``--config`` is a *global* option on cline ≥3.x — it must - # precede the subcommand (``mcp add`` rejects it as unknown). - # ``--yes`` skips the interactive add wizard cline 3.x opens. - add_cmd = [ - "cline", - "--config", - cline_dir, - "mcp", - "add", - "--yes", - "dsagt", - "--", - "uv", - "run", - "dsagt-server", - ] - result = subprocess.run( - add_cmd, - cwd=str(working_dir), - capture_output=True, - text=True, - ) - if result.returncode != 0: - detail = (result.stderr or result.stdout).strip() - raise RuntimeError( - f"cline mcp add dsagt failed " - f"(exit {result.returncode}): {detail}" - ) - - mcp_path = Path(cline_dir) / "data" / "settings" / "cline_mcp_settings.json" - if not mcp_path.exists(): - raise RuntimeError( - f"cline mcp add succeeded but {mcp_path} was not created — " - "cline may have changed its config layout." - ) - settings = json.loads(mcp_path.read_text()) + settings = {"mcpServers": {}} + mcp_env = _mcp_env_block(config) - for entry in settings.get("mcpServers", {}).values(): - entry["env"] = mcp_env - mcp_path.write_text(json.dumps(settings, indent=2) + "\n") - actions.append( - f"Registered MCP servers and patched {len(mcp_env)} env vars into {mcp_path}" - ) - return actions + transport: dict = { + "type": "stdio", + "command": "uv", + "args": ["run", "dsagt-server"], + } + if mcp_env: + transport["env"] = mcp_env + settings["mcpServers"]["dsagt"] = {"transport": transport} + settings_path.write_text(json.dumps(settings, indent=2) + "\n") + return [ + f"Wrote {settings_path} ({len(mcp_env)} MCP env vars; loaded via " + "CLINE_MCP_SETTINGS_PATH at dsagt start)" + ] def runtime_env(self, config: dict) -> dict[str, str]: - """BYOA infrastructure: per-project ``CLINE_DIR`` (state dir). + """BYOA infrastructure: point cline at the per-project MCP settings. - Isolates per-project MCP / auth state from the global - ``~/.cline-data``. + ``CLINE_MCP_SETTINGS_PATH`` relocates ONLY the MCP settings file — + ``providers.json`` (auth) resolves independently, so the user's + subscription/OAuth login keeps working and no dsagt entry ever + lands in the global settings. """ env = super().runtime_env(config) - env["CLINE_DIR"] = str(Path(config["project_dir"]) / ".cline-data") + env["CLINE_MCP_SETTINGS_PATH"] = str( + Path(config["project_dir"]) / ".cline-data" / "cline_mcp_settings.json" + ) return env def run_script( diff --git a/tests/test_config.py b/tests/test_config.py index 1b54682..8b7c1ad 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -515,10 +515,7 @@ def test_update_cursor_roundtrip(self, tmp_path): # Per-agent record writers: static_agent_record + dynamic_agent_record # # Each test runs both writers against a project init'd with --agent, -# mirroring what `dsagt start` does in production. Cline's dynamic -# writer would shell out to `cline auth` here, so cline gets exercised -# by ``test_cline_mcp_config_shape`` (mocked subprocess) instead of -# the full dynamic writer. +# mirroring what `dsagt start` does in production. # --------------------------------------------------------------------------- @@ -565,19 +562,58 @@ def test_goose_writes_goose_yaml(self, tmp_path): assert goose["extensions"]["dsagt"]["cmd"] == "uv run dsagt-server" assert (working_dir / ".goosehints").exists() - def test_cline_writes_static_only_in_split_test(self, tmp_path): - # Cline's dynamic writer shells out to `cline auth` and `cline mcp - # add`, which would fail without cline installed. Test only the - # static half here; ``test_cline_mcp_config_shape`` covers the - # dynamic half with mocked subprocess. + def test_cline_writes_project_mcp_settings(self, tmp_path): + """Cline's dynamic writer hand-writes the per-project MCP settings + file (no cline binary needed); runtime_env points cline at it via + CLINE_MCP_SETTINGS_PATH, leaving global auth + settings untouched.""" + import json as _json + + from dsagt.agents import AGENTS + config = self._init_and_load("cline") working_dir = tmp_path / "workdir" working_dir.mkdir() static_agent_record(config, config["agent"], working_dir) + assert (working_dir / ".clinerules" / "dsagt_instructions.md").exists() + + dynamic_agent_record(config, env={}, working_dir=working_dir) + settings_path = working_dir / ".cline-data" / "cline_mcp_settings.json" + settings = _json.loads(settings_path.read_text()) + transport = settings["mcpServers"]["dsagt"]["transport"] + assert transport["type"] == "stdio" + assert [transport["command"], *transport["args"]] == [ + "uv", + "run", + "dsagt-server", + ] + assert "DSAGT_PROJECT_DIR" in transport["env"] + + env = AGENTS["cline"]().runtime_env(config) + assert env["CLINE_MCP_SETTINGS_PATH"].endswith( + ".cline-data/cline_mcp_settings.json" + ) + assert "CLINE_DIR" not in env + + def test_cline_dynamic_preserves_user_mcp_entries(self, tmp_path): + """Re-running the writer keeps non-dsagt servers the user added.""" + import json as _json + + config = self._init_and_load("cline") + working_dir = tmp_path / "workdir" + working_dir.mkdir() + + dynamic_agent_record(config, env={}, working_dir=working_dir) + settings_path = working_dir / ".cline-data" / "cline_mcp_settings.json" + settings = _json.loads(settings_path.read_text()) + settings["mcpServers"]["mytool"] = { + "transport": {"type": "stdio", "command": "mytool", "args": []} + } + settings_path.write_text(_json.dumps(settings)) - instructions = working_dir / ".clinerules" / "dsagt_instructions.md" - assert instructions.exists() + dynamic_agent_record(config, env={}, working_dir=working_dir) + settings = _json.loads(settings_path.read_text()) + assert set(settings["mcpServers"]) == {"dsagt", "mytool"} assert (working_dir / ".cline-data").is_dir() def test_codex_writes_static_and_dynamic(self, tmp_path): From d70706e48d692f208da2139fe48cae24f8aa752f Mon Sep 17 00:00:00 2001 From: aarontuor Date: Thu, 2 Jul 2026 15:39:54 -0700 Subject: [PATCH 35/44] docs(use_cases): current-dsagt stale sweep + estimated-time headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Non-invasive correctness pass across the use-case walkthroughs (comb-flow-uni left untouched by request). Each demo gets an 'Estimated time' header with an honest runtime + data/credential gate. Uniform staleness fixed: - tool→code terminology; save_tool_spec→save_code_spec; tools/.md→ codes//SKILL.md (registry is skill-standard dirs now). - removed commands dropped: dsagt mlflow / dsagt stop / dsagt setup-kb / dsagt skills — replaced with the current init/start/info surface or the MCP-tool equivalents. - serverless observability: deleted the tokamak OTEL_* export block and 'dsagt mlflow' server start; point at sqlite:////mlflow.db and the 'mlflow ui --backend-store-uri' view. - BYOA: dropped 'set your API keys in dsagt_config.yaml' edits; agents are pre-authenticated. Config path corrected to .dsagt/config.yaml. - isaac_skills_demo: 'scientific' skill source → 'k-dense-ai' (the real KNOWN_SOURCES alias); setup uses 'dsagt init --exclude genesis'. Per-folder: - isaac_vasp: rewrote the 12-line stub into a real ~15-min walkthrough that registers the bundled NEB converter as a code and runs it via dsagt-run on the vendored fixture data; fixed a dangling script ref (ase_slab_db_to_isaac → ase_db_to_isaac) in the skill. - tokamak_stability: README + AGENTS.md + m3dc1-skill swept (filenames like m3dc1_tools.py preserved). - .gitignore: guard mlflow.db + mlruns/ so session trace dumps never land in git again. Deferred (flagged, not done): externalizing isaac_vasp's 32 MB OUTCAR fixtures — the pymatgen source path 404s and I couldn't verify a fetch URL without risking a broken demo. comb-flow-uni left entirely as-is. Co-Authored-By: Claude Fable 5 --- .gitignore | 11 ++ .../aidrin_readiness/aidrin_full_tour_demo.md | 34 +++--- .../aidrin_readiness/cryoem_readiness_demo.md | 34 +++--- use_cases/cryoem/cryoem_demo.md | 43 ++++--- use_cases/fusion-fm/README.md | 11 +- use_cases/genesis_skills/README.md | 26 ++--- use_cases/isaac_skills_demo/README.md | 55 +++++---- use_cases/isaac_vasp/README.md | 105 ++++++++++++++++-- .../references/cathub_organize.md | 6 +- use_cases/microbial_isolates/isolate_demo.md | 26 +++-- use_cases/tokamak_stability/AGENTS.md | 4 +- use_cases/tokamak_stability/README.md | 49 ++++---- .../skills/m3dc1-skill/SKILL.md | 26 ++--- 13 files changed, 280 insertions(+), 150 deletions(-) diff --git a/.gitignore b/.gitignore index 671a964..cb03964 100644 --- a/.gitignore +++ b/.gitignore @@ -105,6 +105,17 @@ test_registry.yaml test_runtime/ *.log provenance.log +# Serverless MLflow store + any stray file-store trace dumps — never commit +# a session's traces (they carry machine-local artifact paths). Guards +# against re-committing dumps like the pre-existing comb-flow-uni/**/mlruns/. +mlflow.db +mlruns/ +latex/ +design-notes/ +scratch/ +env_examples/ +notes.txt +uv.lock # Demo outputs demo/output/ diff --git a/use_cases/aidrin_readiness/aidrin_full_tour_demo.md b/use_cases/aidrin_readiness/aidrin_full_tour_demo.md index 6557dc2..e8715d0 100644 --- a/use_cases/aidrin_readiness/aidrin_full_tour_demo.md +++ b/use_cases/aidrin_readiness/aidrin_full_tour_demo.md @@ -1,5 +1,10 @@ # DSAgt Demo: Full AIDRIN Feature Tour +> **Estimated time:** ~30–40 minutes — this is a long-form tour, not a quick +> demo. Most of the time is the one-time AIDRIN build (clone + `pip install -e` +> in a Python 3.10 venv) plus the agent issuing ~15 separate metric runs. The +> dataset ships with AIDRIN (no large download). + This guide drives **every** [AIDRIN](https://github.com/idtlab/AIDRIN) (AI Data Readiness Inspector) metric through DSAgt on a single tabular dataset — exercising all 15 metrics across all four categories, with full execution provenance. It is the companion to the @@ -22,9 +27,9 @@ prediction **target** (`income`). ## Prerequisites -- DSAgt installed (`uv sync --all-groups`) and an agent platform installed (e.g. `claude`). -- `dsagt_config.yaml` configured (the default local embedder needs no API key; the agent needs its - own LLM credentials). +- DSAgt installed (`uv sync --all-groups`) and an agent platform installed and **already + authenticated** (BYOA — your agent talks to its own LLM provider; dsagt writes no + credentials). The default local embedder needs no API key. - **AIDRIN** installed from its `develop` branch in its own Python 3.10 virtual environment. - Git installed. (No large download — the dataset ships with AIDRIN.) @@ -40,7 +45,6 @@ AIDRIN_BIN="$(pwd)/aidrin-venv/bin/aidrin"; echo "$AIDRIN_BIN" deactivate dsagt init aidrin-tour --agent claude -# Edit ~/dsagt-projects/aidrin-tour/dsagt_config.yaml — set the agent's LLM credentials. PROJ=~/dsagt-projects/aidrin-tour mkdir -p "$PROJ/data" cp AIDRIN/examples/sample_data/csv/adult.csv "$PROJ/data/" @@ -54,18 +58,18 @@ Paste these prompts one at a time (substitute the absolute `$AIDRIN_BIN` path). ### 1. Register the AIDRIN CLI ```text -Register a data-readiness CLI named aidrin into the tool registry. The executable is at +Register a data-readiness CLI named aidrin into the code registry. The executable is at . Run " --help" and " list" to discover its subcommands and the -15 metrics, then save a tool spec named aidrin describing the run/batch/data-quality subcommands +15 metrics, then save a code spec named aidrin describing the run/batch/data-quality subcommands and their positional arguments. ``` -**Verify:** `Search the registry for the aidrin data-readiness tool.` +**Verify:** `Search the registry for the aidrin data-readiness code.` ### 2. Run all 15 metrics through `dsagt-run` ```text -Using the registry aidrin tool, run AIDRIN's full readiness assessment on data/adult.csv, executing +Using the registry aidrin code, run AIDRIN's full readiness assessment on data/adult.csv, executing every metric through dsagt-run so each is recorded. Cover all four categories: (1) data-quality: completeness, duplicity, outliers; (2) impact-of-data-on-AI: correlations on "age,education.num,sex,race", and feature-relevance with @@ -129,7 +133,7 @@ quasi-identifiers — bin or suppress before sharing. ```text Write an aidrin batch config (YAML) that runs completeness, class-imbalance, statistical-rates, and representation-rate on data/adult.csv with target income and sensitive attribute sex, then run it -through the registry aidrin tool. +through the registry aidrin code. ``` Batch config keys: `file-path`, `file-type`, `metrics`, `target-column`, @@ -153,7 +157,7 @@ Reconstruct the full readiness assessment you just ran from the execution record ## Post-Conditions -1. Tool registry contains the `aidrin` spec (`tools/aidrin.md`). +1. Code registry contains the `aidrin` spec (`codes/aidrin/SKILL.md`). 2. `trace_archive/` holds one provenance record per metric run (15 from step 2). 3. Results span all four categories, with the gender-fairness gap and the `k = 1` / `l = 1` re-identification risks identified. @@ -165,19 +169,21 @@ Reconstruct the full readiness assessment you just ran from the execution record | DSAgt Capability | Steps | |------------------|-------| -| External-CLI registration (`save_tool_spec`) | 1 | +| External-CLI registration (`save_code_spec`) | 1 | | Registry search | 1 (Verify) | -| Tool execution with provenance (`dsagt-run` → `trace_archive/`) | 2 | +| Code execution with provenance (`dsagt-run` → `trace_archive/`) | 2 | | Full-suite (15-metric) orchestration | 2 | | Multi-metric / batch execution | 3 | | Skill discovery and use (datacard generation) | 4 | | Pipeline reconstruction from execution records | 5 | -| Observability (MLflow spans) | all | +| Observability (MLflow spans in the serverless `mlflow.db` store) | all | + +View the traces any time with +`mlflow ui --backend-store-uri sqlite:///$PROJ/mlflow.db`. ## Cleanup ```bash -dsagt stop aidrin-tour dsagt rm aidrin-tour -y rm -rf AIDRIN aidrin-venv ``` diff --git a/use_cases/aidrin_readiness/cryoem_readiness_demo.md b/use_cases/aidrin_readiness/cryoem_readiness_demo.md index c4eaf42..e135457 100644 --- a/use_cases/aidrin_readiness/cryoem_readiness_demo.md +++ b/use_cases/aidrin_readiness/cryoem_readiness_demo.md @@ -1,5 +1,10 @@ # DSAgt Demo: AIDRIN Readiness Gate on a Cryo-EM Pipeline +> **Estimated time:** ~30 minutes, dominated by a **~2 GB EMPIAR-10017 +> download** (from `calla.rnet.missouri.edu`) plus the one-time AIDRIN build. +> The agent portion (register + before/after assessment + datacard) is ~10 +> minutes once the data is staged. + This guide demonstrates DSAgt using [AIDRIN](https://github.com/idtlab/AIDRIN) (AI Data Readiness Inspector) as a **readiness gate** around a cryo-EM data-curation step. The agent registers the AIDRIN CLI, then runs the applicable readiness metrics **before and after** particle curation to @@ -31,9 +36,9 @@ tabular dataset where they do apply. ## Prerequisites -- DSAgt installed (`uv sync --all-groups`) and an agent platform installed (e.g. `claude`). -- `dsagt_config.yaml` configured (the default local embedder needs no API key; the agent needs its - own LLM credentials). +- DSAgt installed (`uv sync --all-groups`) and an agent platform installed and **already + authenticated** (BYOA — dsagt writes no credentials). The default local embedder needs no + API key. - **AIDRIN** installed from its `develop` branch in its own Python 3.10 virtual environment (see Setup). - ~2 GB disk for the EMPIAR-10017 cryo-EM data. @@ -85,7 +90,6 @@ deactivate ```bash dsagt init cryoem-readiness --agent claude -# Edit ~/dsagt-projects/cryoem-readiness/dsagt_config.yaml — set the agent's LLM credentials. PROJ=~/dsagt-projects/cryoem-readiness mkdir -p "$PROJ/data" cp demo_data/cryoem/cryoem_before.csv demo_data/cryoem/cryoem_after.csv "$PROJ/data/" @@ -99,19 +103,19 @@ Paste these prompts into the agent one at a time (substitute the absolute `$AIDR ### 1. Register the AIDRIN CLI ```text -Register a data-readiness CLI named aidrin into the tool registry. The executable is at +Register a data-readiness CLI named aidrin into the code registry. The executable is at . Run " --help" and " list" to discover its subcommands and the -15 metrics, then save a tool spec named aidrin describing the run/batch/data-quality subcommands +15 metrics, then save a code spec named aidrin describing the run/batch/data-quality subcommands and their positional arguments. ``` -**Verify:** `Search the registry for the aidrin data-readiness tool.` → -`~/dsagt-projects/cryoem-readiness/tools/aidrin.md` should exist. +**Verify:** `Search the registry for the aidrin data-readiness code.` → +`~/dsagt-projects/cryoem-readiness/codes/aidrin/SKILL.md` should exist. ### 2. Readiness assessment BEFORE curation ```text -Using the registry aidrin tool, assess the AI data readiness of data/cryoem_before.csv. Run these +Using the registry aidrin code, assess the AI data readiness of data/cryoem_before.csv. Run these metrics through dsagt-run: completeness; duplicity; outliers; correlations on the columns "Defocus U,Defocus V,Defocus Angle,CTF B Factor,Origin X (Ang),Origin Y (Ang)"; class-imbalance on the Class Number column; and feature-relevance with no categorical columns, those same numerical @@ -157,7 +161,7 @@ Reconstruct the readiness assessment you just ran from the execution records as ## Post-Conditions -1. Tool registry contains the `aidrin` spec (`tools/aidrin.md`). +1. Code registry contains the `aidrin` spec (`codes/aidrin/SKILL.md`). 2. `trace_archive/` holds one provenance record per metric run (before and after). 3. Before/after scores show curation reduced outliers (~0.041 → ~0.029) and class imbalance (~22.2 → ~11.1) while completeness and duplicity stayed clean. @@ -169,18 +173,20 @@ Reconstruct the readiness assessment you just ran from the execution records as | DSAgt Capability | Steps | |------------------|-------| -| External-CLI registration (`save_tool_spec`) | 1 | +| External-CLI registration (`save_code_spec`) | 1 | | Registry search | 1 (Verify) | -| Tool execution with provenance (`dsagt-run` → `trace_archive/`) | 2, 3 | +| Code execution with provenance (`dsagt-run` → `trace_archive/`) | 2, 3 | | Before/after comparison driven by the agent | 3 | | Skill discovery and use (datacard generation) | 4 | | Pipeline reconstruction from execution records | 5 | -| Observability (MLflow spans) | all | +| Observability (MLflow spans in the serverless `mlflow.db` store) | all | + +View the traces any time with +`mlflow ui --backend-store-uri sqlite:///$PROJ/mlflow.db`. ## Cleanup ```bash -dsagt stop cryoem-readiness dsagt rm cryoem-readiness -y rm -rf demo_data/cryoem AIDRIN aidrin-venv ``` diff --git a/use_cases/cryoem/cryoem_demo.md b/use_cases/cryoem/cryoem_demo.md index ec86c4e..173dc34 100644 --- a/use_cases/cryoem/cryoem_demo.md +++ b/use_cases/cryoem/cryoem_demo.md @@ -1,12 +1,18 @@ # DSAgt Demo: Cryo-EM Data Curation Pipeline -This guide documents a comprehensive DSAgt demonstration using cryo-electron microscopy (cryo-EM) data. It exercises knowledge ingestion, KB-guided pipeline design, tool registration from third-party code, cross-collection knowledge synthesis, and multi-stage pipeline execution with domain-specific evaluation. +> **Estimated time:** ~40 minutes — this is the broadest demo (7 stages) and +> the least quick. It pulls a **~2 GB EMPIAR-10017 download**, an arXiv PDF, +> and a full CryoPPP repo clone, then KB-ingests the whole repo (minutes on the +> local embedder) before any pipeline work. Best treated as an advanced, +> bring-time walkthrough rather than a 10-minute demo. + +This guide documents a comprehensive DSAgt demonstration using cryo-electron microscopy (cryo-EM) data. It exercises knowledge ingestion, KB-guided pipeline design, code registration from third-party scripts, cross-collection knowledge synthesis, and multi-stage pipeline execution with domain-specific evaluation. ## Prerequisites - DSAgt installed (`uv sync --all-groups`) -- An agent platform installed (e.g., `claude` for Claude Code) -- `test_site_config.yaml` configured with valid API keys and embedding endpoint +- An agent platform installed and **already authenticated** (e.g., `claude` for Claude Code) + — BYOA: dsagt writes no credentials. The default local embedder needs no API key. - ~2 GB disk space for the cryo-EM test data - Git installed @@ -44,7 +50,9 @@ git clone https://github.com/BioinfoMachineLearning/cryoppp.git demo_repos/cryop dsagt init cryoem-pipeline --agent claude ``` -Edit `~/dsagt-projects/cryoem-pipeline/dsagt_config.yaml` — set your API keys and embedding endpoint. +(The default local embedder needs no key. To use a hosted embedder instead, set +`embedding.backend: api` in `~/dsagt-projects/cryoem-pipeline/.dsagt/config.yaml` +and export `EMBEDDING_API_KEY` in your shell — never written to disk.) ### 5. Start the session @@ -82,19 +90,19 @@ Search the cryoppp collection for guidance on creating an AI-ready data processi The agent should return chunks describing quality metrics: CTF resolution, defocus ranges, ice thickness thresholds, and motion statistics. -### 3. Register CryoPPP processing tools +### 3. Register CryoPPP processing codes ```text -Look at the scripts in demo_repos/cryoppp/ and register any data processing or evaluation tools you find. Run --help on each script to discover its interface. +Look at the scripts in demo_repos/cryoppp/ and register any data-processing or evaluation codes you find. Run --help on each script to discover its interface. ``` **Verify:** ```text -Search the registry for cryo-EM tools. +Search the registry for cryo-EM codes. ``` -### 4. Create a quality scoring tool +### 4. Create a quality scoring code ```text Write a Python script that scores cryo-EM micrographs based on: @@ -103,10 +111,10 @@ Write a Python script that scores cryo-EM micrographs based on: - Ice thickness - Motion statistics -Use the CryoCRAB 0-7 scoring scheme described in the cryoppp collection. The script should read a metadata CSV and output a scored CSV with quality_score and quality_tier (high/medium/low) columns. Save the script under tools/code/ and register it as a tool. +Use the CryoCRAB 0-7 scoring scheme described in the cryoppp collection. The script should read a metadata CSV and output a scored CSV with quality_score and quality_tier (high/medium/low) columns. Save the script under codes//scripts/ and register it as a code. ``` -The agent should search the knowledge base, write the script, and register it via `save_tool_spec`. +The agent should search the knowledge base, write the script, and register it via `save_code_spec`. ### 5. Run the pipeline @@ -133,12 +141,12 @@ Reconstruct the pipeline from the execution records as a bash script. ## Post-Conditions 1. Knowledge base contains `cryoppp` collection with repo code, docs, and appended paper. -2. Tool registry includes CryoPPP processing tools and the quality scoring tool. +2. Code registry includes the CryoPPP processing codes and the quality-scoring code. 3. Quality-scored CSV exists with tier distribution. 4. A datacard exists for the processed dataset. 5. A reconstructed pipeline script is available. -6. Tool execution records in `trace_archive/` document the full provenance chain. -7. MLflow traces capture token usage, latency, and full request/response history. +6. Code execution records in `trace_archive/` document the full provenance chain. +7. MLflow traces (in the serverless `mlflow.db` store) capture token usage, latency, and full request/response history. ## What This Tests @@ -147,15 +155,16 @@ Reconstruct the pipeline from the execution records as a bash script. | Knowledge ingestion (folder) | 1 | | Knowledge append (single file) | 1 | | Semantic search | 2 | -| Tool discovery via registry | 3 | -| Tool registration | 3, 4 | +| Code discovery via registry | 3 | +| Code registration | 3, 4 | | KB-guided code generation | 4 | -| Tool execution with provenance | 5 | +| Code execution with provenance | 5 | | Skill discovery and use | 6 | | Pipeline reconstruction | 7 | ## Cleanup ```bash -rm -rf ~/dsagt-projects/cryoem-pipeline demo_data/cryoem demo_repos/cryoppp +dsagt rm cryoem-pipeline -y # unregisters the project and removes its dir +rm -rf demo_data/cryoem demo_repos/cryoppp ``` diff --git a/use_cases/fusion-fm/README.md b/use_cases/fusion-fm/README.md index 858a18a..14053ef 100644 --- a/use_cases/fusion-fm/README.md +++ b/use_cases/fusion-fm/README.md @@ -1,5 +1,13 @@ # Fusion Foundation Model — XGC AI Training Use Case +> **Estimated time:** ~30 minutes on an HPC login node — **not a 10-minute demo +> and data is not included.** This is an advanced, bring-your-own-data example: +> the XGC cases below are HPC-scale ADIOS2 BP5 output (up to ~1.3M mesh nodes) +> that must be supplied by the user, and the scripts require `adios2` + `torch` +> plus MATEY's `BaseCFDGraphDataset`. Point the paths below at your own XGC run. +> DSAgt drives this via the bundled skill (see **Skill** below), which registers +> and runs these scripts as codes with provenance. + **Domain:** Plasma physics — gyrokinetic turbulence simulation **Simulation code:** XGC (X-point Gyrokinetic Code) **Data format:** ADIOS2 BP5 (one directory per simulation run) @@ -7,7 +15,8 @@ ## Data -Three example simulation cases live in `example_xgc_data/`: +Three example simulation cases are referenced under `example_xgc_data/` (**not +shipped in-repo** — substitute your own XGC output directories): | Case | Machine | Nodes | nphi | Steps | Has f3d | |------|---------|-------|------|-------|---------| diff --git a/use_cases/genesis_skills/README.md b/use_cases/genesis_skills/README.md index 8c22c50..08727db 100644 --- a/use_cases/genesis_skills/README.md +++ b/use_cases/genesis_skills/README.md @@ -1,5 +1,9 @@ # DSAgt Demo: Genesis Skills for a Data-Curation Pipeline +> **Estimated time:** ~10 minutes (all data is bundled and tiny; the one +> external dependency is a shallow clone of the Genesis catalog from OSTI +> GitLab — needs network access to `gitlab.osti.gov`). + An end-to-end **data-preparation** walkthrough that flexes the skill catalog against the **Genesis** source (OSTI GitLab). The agent pulls in the BASE-Data/ModCon curation skills, grounds itself in domain context loaded into @@ -37,15 +41,13 @@ semantic search when `EMBEDDING_*` is set and fall back to a keyword scorer otherwise (configure it for sharper relevance over the domain docs). ```bash -dsagt setup-kb # bundled tools/skills + core KB -dsagt init genesis-skills --agent claude +dsagt init genesis-skills --agent claude # provisions the bundled codes/skills + core KB cp -r use_cases/genesis_skills/mock_data ~/dsagt-projects/genesis-skills/mock_data dsagt start genesis-skills # mirrors skill-creator into .claude/skills/ before launch ``` The Genesis catalog is **project-scoped** and not synced by `init`, so the agent -enables it in step 1 below (or run `dsagt skills add genesis-skills genesis` from -a shell first). +enables it in step 1 below (via the `add_skill_source` MCP tool). ## Walkthrough @@ -57,13 +59,13 @@ and check the expected behavior. *Expect:* `add_skill_source(source="genesis")` → a shallow clone of OSTI GitLab, **74** skills indexed into `skills_catalog__genesis-genesis-skills`, source -written to `dsagt_config.yaml`. (Two upstream skills — including +written to `.dsagt/config.yaml`. (Two upstream skills — including `datacard-generator` — have technically-invalid YAML frontmatter; dsagt recovers their name/description with a lenient fallback rather than dropping them, so they *are* searchable.) Confirm from a shell: ```bash -dsagt skills list genesis-skills --catalog # expect the skills_catalog__genesis-genesis-skills collection +dsagt info genesis-skills # the skills_catalog__genesis-genesis-skills collection appears in the KB summary ``` ### 2 — Find and install the datacard skill (catalog, NOT in context) @@ -122,9 +124,8 @@ schema issues. ### 8 — Inspect the tiers (run in a shell) ```bash -dsagt skills list genesis-skills # installed: skill-creator + generating-datacards + croissant-validator -dsagt skills list genesis-skills --catalog # catalog: skills_catalog__genesis-genesis-skills -dsagt info genesis-skills # KB shows methanation_domain +dsagt info genesis-skills # KB shows the genesis catalog collection + methanation_domain +ls ~/dsagt-projects/genesis-skills/skills/ # installed: generating-datacards + croissant-validator ls ~/dsagt-projects/genesis-skills/.claude/skills/ cat ~/dsagt-projects/genesis-skills/.claude/skills/.dsagt-managed.json ls ~/dsagt-projects/genesis-skills/audit/ # catalyst_screening_datacard.md @@ -150,7 +151,6 @@ Genesis skills as native auto-invoked skills. ## Cleanup ```bash -dsagt stop genesis-skills dsagt rm genesis-skills # add -y to skip the prompt ``` @@ -160,8 +160,8 @@ reused across projects; delete it to force a fresh clone. ## Notes - The Genesis catalog is hosted on **OSTI GitLab** (`gitlab.osti.gov`), not - GitHub — reached the same way as any other source (`add_skill_source` / - `dsagt skills add … genesis`); only the host differs. + GitHub — reached the same way as any other source (the `add_skill_source` + MCP tool); only the host differs. - `generating-datacards` is the frontmatter *name* of the skill whose directory is `datacard-generator` in the Genesis repo — `install_skill` accepts either. It and `croissant-validator` live under Genesis's `modcon-skills/` category @@ -172,4 +172,4 @@ reused across projects; delete it to force a fresh clone. skill owns the authoritative template. - Sister demo: [`isaac_skills_demo`](../isaac_skills_demo/) flexes the same catalog → install → native-mirror loop plus authoring a new skill with - `skill-creator`, against the K-Dense `scientific` (GitHub) source. + `skill-creator`, against the K-Dense `k-dense-ai` (GitHub) source. diff --git a/use_cases/isaac_skills_demo/README.md b/use_cases/isaac_skills_demo/README.md index 16502d6..8daf6e5 100644 --- a/use_cases/isaac_skills_demo/README.md +++ b/use_cases/isaac_skills_demo/README.md @@ -1,5 +1,10 @@ # DSAgt Demo: Skill-Driven VASP → ISAAC Conversion +> **Estimated time:** ~15 minutes — the agent flow runs in seconds on the +> bundled few-KB mock data; the one real cost is a one-time +> `pip install pymatgen` (needed for step 6) plus a shallow clone of the +> K-Dense catalog from GitHub. + A lightweight mock of the [`isaac_vasp`](../isaac_vasp/) workflow, built to **vet the skill-management feature**. It follows the same arc as `isaac_vasp` — install the **pymatgen** skill, author a `vasp-to-isaac` converter that parses VASP output @@ -14,8 +19,8 @@ stand-in. - **`list_skill_sources`** — the agent discovers what external sources it can pull from (curated names + arbitrary git URLs) and which are synced. -- **`add_skill_source`** — the agent syncs a source (default: K-Dense - `scientific-agent-skills`, 140+ skills) into a searchable catalog that is +- **`add_skill_source`** — the agent syncs a source (here: K-Dense + `k-dense-ai`, 140+ skills) into a searchable catalog that is **not** loaded into context; with the single `dsagt-server` it's searchable immediately, no restart. - **`search_skills`** + **`install_skill`** — find a catalog skill (hits marked @@ -39,24 +44,20 @@ credentials are optional — `search_skills` uses semantic search when for sharper relevance). ```bash -dsagt setup-kb --no-skill-catalog # core KB only — the agent syncs the catalog in-session (step 3) -dsagt init isaac-skills-demo --agent claude +dsagt init isaac-skills-demo --agent claude --exclude genesis # core KB + bundled codes/skills, but NO catalog synced cp -r use_cases/isaac_skills_demo/mock_data ~/dsagt-projects/isaac-skills-demo/mock_data dsagt start isaac-skills-demo # mirrors the bundled skill-creator into .claude/skills/ before launch ``` -The project starts with **no external catalog synced** — that's deliberate: the -walkthrough has the agent *discover, sync, and search* it from inside the -session. Now that one `dsagt-server` owns the KB, a source the agent syncs -mid-session is **immediately searchable, no restart** — which the old two-server -split (sync in one process, search in another) couldn't do, so it had to be a -pre-`start` CLI step. +The project starts with **no external catalog synced** — that's deliberate (the +`--exclude genesis` drops the default catalog): the walkthrough has the agent +*discover, sync, and search* it from inside the session. The single +`dsagt-server` owns the KB, so a source the agent syncs mid-session is +**immediately searchable, no restart**. -> Plain `dsagt setup-kb` (without `--no-skill-catalog`) instead pre-syncs the -> default `scientific` source into the shared KB, which `init` then copies in. If -> you do that, step 3 below becomes an idempotent refresh — or just sync a -> different source there (e.g. `anthropic`). The `dsagt skills sync ` CLI -> still exists for scripted/headless setups. +> To instead pre-sync a source at init, pass `--include` with a source name +> (e.g. `dsagt init … --include k-dense-ai`); `init` provisions it into the KB +> and step 3 below becomes an idempotent refresh. ## Walkthrough @@ -80,17 +81,17 @@ reads. ### 2 — Where can we find more skills? > Where can I get more skills from? List the skill sources you can pull from and which are already synced. -*Expect:* `list_skill_sources` → the known sources (`scientific`, `anthropic`, +*Expect:* `list_skill_sources` → the known sources (`k-dense-ai`, `anthropic`, `antigravity`, `composio`, `genesis`) with URLs, each flagged **available, not -synced** (nothing is synced yet on a `--no-skill-catalog` setup). +synced** (nothing is synced yet on an `--exclude genesis` setup). ### 3 — Sync skills from an external repo -> Sync the "scientific" source so we can search its catalog. +> Sync the "k-dense-ai" source so we can search its catalog. -*Expect:* `add_skill_source(source="scientific")` → a shallow clone of K-Dense +*Expect:* `add_skill_source(source="k-dense-ai")` → a shallow clone of K-Dense `scientific-agent-skills`, ~140 skills indexed into `skills_catalog__k-dense-ai-scientific-agent-skills`, source persisted to -`dsagt_config.yaml`. Because it's one `dsagt-server`, the catalog is searchable +`.dsagt/config.yaml`. Because it's one `dsagt-server`, the catalog is searchable **immediately** — the next prompt can hit it with no restart. ### 4 — Add the relevant skill @@ -128,8 +129,8 @@ reference. (`pymatgen` must be importable in the project env — see Notes.) ### 7 — Inspect the tiers (run in a shell) ```bash -dsagt skills list isaac-skills-demo # installed: skill-creator + pymatgen + vasp-to-isaac -dsagt skills list isaac-skills-demo --catalog # catalog: skills_catalog__k-dense-ai-scientific-agent-skills +dsagt info isaac-skills-demo # KB shows the k-dense-ai catalog collection +ls ~/dsagt-projects/isaac-skills-demo/skills/ # installed: pymatgen + vasp-to-isaac ls ~/dsagt-projects/isaac-skills-demo/.claude/skills/ cat ~/dsagt-projects/isaac-skills-demo/.claude/skills/.dsagt-managed.json ``` @@ -155,7 +156,6 @@ native auto-invoked skills. ## Cleanup ```bash -dsagt stop isaac-skills-demo dsagt rm isaac-skills-demo # add -y to skip the prompt ``` @@ -180,11 +180,10 @@ reused across projects; delete it to force a fresh clone. still correct (pymatgen #1). Switch `embedding.backend` to `api` for sharper relevance. With no embedder at all, `search_skills` falls back to keyword scoring; `install_skill` and the native mirror are pure filesystem ops. -- Add **more** sources the same way, in-session or from a shell — e.g. ask the - agent to "enable the anthropic source", or run - `dsagt skills add isaac-skills-demo antigravity` (or `composio`, `genesis`, or - any `https://github.com/owner/repo`). Each lands in its own - `skills_catalog__*` collection. +- Add **more** sources the same way — ask the agent to "enable the anthropic + source" (or `antigravity`, `composio`, `genesis`, or any + `https://github.com/owner/repo`), which fires `add_skill_source`. Each lands + in its own `skills_catalog__*` collection. - Sister demo: [`genesis_skills`](../genesis_skills/) flexes the same catalog → install → native loop plus KB domain ingest and datacard generation, against the Genesis (OSTI GitLab) source. diff --git a/use_cases/isaac_vasp/README.md b/use_cases/isaac_vasp/README.md index 2199ade..02c141f 100644 --- a/use_cases/isaac_vasp/README.md +++ b/use_cases/isaac_vasp/README.md @@ -1,12 +1,103 @@ -# VASP -> ISAAC AI Ready Data conversion +# DSAgt Demo: VASP → ISAAC AI-Ready Record -**Goal:** Generate ISAAC AI-Ready Records (https://github.com/ISAAC-DOE/isaac-ai-ready-record) +> **Estimated time:** ~15 minutes — the NEB fixture data is bundled in this +> folder, so the only real cost is a one-time `pip install pymatgen`. No DFT run, +> no HPC. -See schema: https://github.com/ISAAC-DOE/isaac-ai-ready-record/blob/main/schema/isaac_record_v1.json +**Goal:** register a converter as a DSAgt **code**, then have the agent run it +through `dsagt-run` to turn a VASP nudged-elastic-band (NEB) calculation into an +[ISAAC AI-Ready Record](https://github.com/ISAAC-DOE/isaac-ai-ready-record) — +with the execution captured in `trace_archive/`. + +- Converter: [`vasp_neb_to_isaac.py`](vasp_neb_to_isaac.py) parses the NEB + `OUTCAR`s with `pymatgen.io.vasp` and emits a v1.05 record. +- Data: the `neb/00..04/` subdirs are a small NEB fixture (5 images) copied from + the pymatgen test suite — bundled here so the demo runs without a DFT code. +- Schema: [isaac_record_v1.json](https://github.com/ISAAC-DOE/isaac-ai-ready-record/blob/main/schema/isaac_record_v1.json). + +For the **skill-management** counterpart of this workflow (the agent discovers, +installs, and authors the converter as a skill on tiny mock data), see the +sister demo [`isaac_skills_demo`](../isaac_skills_demo/). + +## Prerequisites + +- DSAgt installed (`uv sync --all-groups`) and an agent platform installed and + **already authenticated** (BYOA — dsagt writes no credentials; the default + local embedder needs no API key). +- `pymatgen` importable in the environment `dsagt` runs in + (`uv pip install pymatgen`) — the converter uses `pymatgen.io.vasp`. + +## Setup + +```bash +uv pip install pymatgen # the converter's one real dependency +dsagt init isaac-vasp --agent claude +PROJ=~/dsagt-projects/isaac-vasp +mkdir -p "$PROJ/codes/scripts" +cp use_cases/isaac_vasp/vasp_neb_to_isaac.py "$PROJ/codes/scripts/" +cp -r use_cases/isaac_vasp/neb "$PROJ/data_neb" +dsagt start isaac-vasp +``` + +## Execution + +Paste each prompt into the agent, one at a time. + +### 1. Register the converter as a code + +```text +Register a code named vasp-neb-to-isaac. Its executable is +`python codes/scripts/vasp_neb_to_isaac.py`, which takes a positional NEB +directory argument (containing 00/, 01/, ... image subdirs) and an optional +`--output` path. Run it with `--help` first to confirm the interface, then save +the code spec with the positional `neb_dir` and the `--output` option. +``` + +**Verify:** `Search the registry for the vasp-neb-to-isaac code.` → +`$PROJ/codes/vasp-neb-to-isaac/SKILL.md` should exist. + +### 2. Run the conversion through dsagt-run + +```text +Using the registered vasp-neb-to-isaac code, convert the NEB calculation in +data_neb/ and write the ISAAC record to data_neb/isaac_neb_record.json. Use the +exact dsagt-run command from the spec so the execution is recorded. Then tell me +the record's reaction-energy / barrier fields and how many images it summarized. +``` + +**Expect:** the agent runs `dsagt-run --code vasp-neb-to-isaac -- python +codes/scripts/vasp_neb_to_isaac.py data_neb/ --output data_neb/isaac_neb_record.json`, +pymatgen parses the five OUTCARs, and a v1.05 ISAAC record lands with the +`computation` / `measurement` blocks populated (5 NEB images). Compare against the +reference [`isaac_neb_record.json`](isaac_neb_record.json) shipped here. + +### 3. Reconstruct the pipeline + +```text +Reconstruct the pipeline from the execution records as a script. +``` + +## Post-Conditions + +1. Code registry contains the `vasp-neb-to-isaac` spec (`codes/vasp-neb-to-isaac/SKILL.md`). +2. `trace_archive/` holds the conversion's provenance record. +3. `data_neb/isaac_neb_record.json` is a valid ISAAC v1.05 record matching the + shape of the bundled `isaac_neb_record.json`. +4. MLflow traces (in the serverless `mlflow.db` store) capture the run — + `mlflow ui --backend-store-uri sqlite:///$PROJ/mlflow.db`. + +## Cleanup + +```bash +dsagt rm isaac-vasp -y +``` ## Notes -- We convert example VASP outputs from: -https://github.com/materialsproject/pymatgen/tree/master/test-files/io/vasp/fixtures/neb_analysis/neb2 -- Uses "pymatgen" Skills from: https://github.com/K-Dense-AI/scientific-agent-skills - +- The `neb/` OUTCARs are public pymatgen test fixtures vendored here for a + self-contained demo. They are large (~32 MB total); a future revision may fetch + them on demand instead of shipping them in-repo. +- The bundled `skills/vasp-to-isaac/` skill is a broader **slab/bulk** converter + (a different pymatgen workflow that needs `vasprun.xml`-bearing slab/bulk data, + not the NEB fixture here). It's shipped as a reference skill; the NEB converter + above is what this folder's data actually exercises. diff --git a/use_cases/isaac_vasp/skills/vasp-to-isaac/references/cathub_organize.md b/use_cases/isaac_vasp/skills/vasp-to-isaac/references/cathub_organize.md index b39813e..be31a87 100644 --- a/use_cases/isaac_vasp/skills/vasp-to-isaac/references/cathub_organize.md +++ b/use_cases/isaac_vasp/skills/vasp-to-isaac/references/cathub_organize.md @@ -95,7 +95,7 @@ The `.db` file is a standard ASE database containing one row per structure, with Once the `.db` file is built, convert to ISAAC records: ```bash -python3 ase_slab_db_to_isaac.py /.db ./isaac_records/ --electrode-type anode +python3 ase_db_to_isaac.py /.db ./isaac_records/ --electrode-type anode ``` See `slab_workflow.md` for the full slab pipeline. @@ -107,6 +107,6 @@ See `slab_workflow.md` for the full slab pipeline. | Route | When to use | |---|---| | **cathub organize → folder2db** (this file) | Data is organized as surface reactions with gas references and adsorbate slabs. Produces CatHub-schema entries with reaction energies, `state`, `facet`, `species` key-value pairs. | -| **make_slab_ase_db.py** (direct from VASP) | Raw VASP slab directories without a CatHub reaction structure. Useful for datasets not organized around specific reactions, or when you want richer INCAR-level metadata (smearing, convergence, Hubbard-U) in the db. | +| **make_ase_db.py** (direct from VASP) | Raw VASP slab directories without a CatHub reaction structure. Useful for datasets not organized around specific reactions, or when you want richer INCAR-level metadata (smearing, convergence, Hubbard-U) in the db. | -Both routes produce an ASE SQLite `.db` file that `ase_slab_db_to_isaac.py` can read directly. +Both routes produce an ASE SQLite `.db` file that `ase_db_to_isaac.py` can read directly. diff --git a/use_cases/microbial_isolates/isolate_demo.md b/use_cases/microbial_isolates/isolate_demo.md index b5e6cee..178c5c5 100644 --- a/use_cases/microbial_isolates/isolate_demo.md +++ b/use_cases/microbial_isolates/isolate_demo.md @@ -1,12 +1,18 @@ # DSAgt Demo: Microbial Isolate Processing +> **Estimated time:** advanced / not a 10-minute demo. The isolate data is +> pulled from **NERSC** (requires an account + allocation — step 2), and +> `megahit` assembly runs minutes per sample across ~11 isolates. Treat this as +> a bring-your-own-HPC-data walkthrough; substitute your own FASTQ files for the +> NERSC path if you don't have NERSC access. + This guide documents a reproducible DSAgt demonstration for microbial isolate data processing using `fastp` and `megahit`. ## Prerequisites - DSAgt installed (`uv sync --all-groups`) -- An agent platform installed (e.g., `claude` for Claude Code, or `goose`) -- `test_site_config.yaml` configured with valid API keys and embedding endpoint +- An agent platform installed and **already authenticated** (e.g., `claude` for Claude Code, or + `goose`) — BYOA: dsagt writes no credentials. The default local embedder needs no API key. - Conda (for installing fastp and megahit) ## Setup @@ -21,7 +27,7 @@ conda run -n isolate fastp --version conda run -n isolate megahit --version ``` -Note the conda env prefix (e.g., `~/miniconda3/envs/isolate/bin/`) — you'll reference these paths when registering the tools. +Note the conda env prefix (e.g., `~/miniconda3/envs/isolate/bin/`) — you'll reference these paths when registering the codes. ### 2. Collect data @@ -45,7 +51,9 @@ git clone https://github.com/voutcn/megahit.git demo_repos/megahit dsagt init isolate-pipeline --agent claude ``` -Edit `~/dsagt-projects/isolate-pipeline/dsagt_config.yaml` — set your API keys and embedding endpoint. +(The default local embedder needs no key. To use a hosted embedder instead, set +`embedding.backend: api` in `~/dsagt-projects/isolate-pipeline/.dsagt/config.yaml` +and export `EMBEDDING_API_KEY` in your shell — never written to disk.) ### 5. Start the session @@ -53,7 +61,7 @@ Edit `~/dsagt-projects/isolate-pipeline/dsagt_config.yaml` — set your API keys dsagt start isolate-pipeline ``` -The agent launches from the project directory with MCP servers connected. Services clean up automatically when the agent exits. +The agent launches from the project directory with the MCP server connected. Serverless — there are no background services to clean up. ## Execution @@ -70,7 +78,7 @@ The collection will contain: 4) best practices for fastp and megahit: use_cases/microbial_isolates/fastp_megahit_best_practices.md ``` -### 2. Register tools +### 2. Register codes ```text Let's add /fastp to the registry @@ -113,7 +121,7 @@ The agent calls `reconstruct_pipeline` to generate a reproducible script from th ## Post-Conditions 1. Knowledge base contains collection `microbial_isolates` with all listed references indexed. -2. Tool registry includes `fastp` and `megahit` tool specs (wrapped with `dsagt-run`). +2. Code registry includes `fastp` and `megahit` code specs (wrapped with `dsagt-run`). 3. Processed output directories exist for each isolate sample. 4. For each completed sample: - Preprocessed FASTQ output exists @@ -121,8 +129,8 @@ The agent calls `reconstruct_pipeline` to generate a reproducible script from th - Assembly output exists, including `final.contigs.fa` 5. A Level 1 datacard exists for the processed dataset. 6. A reconstructed pipeline script (bash or Snakemake) is available. -7. Tool execution records in `trace_archive/` document the full provenance chain. -8. MLflow traces capture token usage, latency, and full request/response history. +7. Code execution records in `trace_archive/` document the full provenance chain. +8. MLflow traces (in the serverless `mlflow.db` store) capture token usage, latency, and full request/response history. View with `mlflow ui --backend-store-uri sqlite:///~/dsagt-projects/isolate-pipeline/mlflow.db`. ### Note diff --git a/use_cases/tokamak_stability/AGENTS.md b/use_cases/tokamak_stability/AGENTS.md index 1d44a78..f2f4ea1 100644 --- a/use_cases/tokamak_stability/AGENTS.md +++ b/use_cases/tokamak_stability/AGENTS.md @@ -1,8 +1,8 @@ # Agent Guide - tokamak_stability -## Tools +## Codes -This subdirectory provides python modules containing functions for interacting with HDF5 data files created by the M3D-C1 finite-element simulation code. Before using these functions, and in particular before creating CLI tools based on these functions, read the agent skill file at `skills/m3dc1-skill/SKILL.md` for calling conventions, input/output formats, and examples. +This subdirectory provides python modules containing functions for interacting with HDF5 data files created by the M3D-C1 finite-element simulation code. Before using these functions, and in particular before registering them as CLI codes, read the agent skill file at `skills/m3dc1-skill/SKILL.md` for calling conventions, input/output formats, and examples. The three python modules containing useful functions are: `m3dc1_tools.py`, `m3dc1_plots.py`, and `hdf5.py`. diff --git a/use_cases/tokamak_stability/README.md b/use_cases/tokamak_stability/README.md index e8c5c00..214b8dd 100644 --- a/use_cases/tokamak_stability/README.md +++ b/use_cases/tokamak_stability/README.md @@ -1,6 +1,12 @@ # Fusion energy use case -Here we're going to use dsagt to investigate the stability properties of a tokamak configuration. We'll be looking at linear MHD simulation data produced by the [M3D-C1](https://sites.google.com/pppl.gov/m3d-c1) unstructured-mesh finite-element code. The session below has been tested with Claude Code using Sonnet 4.6. +> **Estimated time:** advanced / not a 10-minute demo. Setup is the cost: +> building the **fusion-io** C/C++ library from source and downloading a +> Google-Drive-hosted **M3D-C1** dataset. Budget an hour+ for first-time setup; +> the agent session itself is ~15 minutes once dependencies and data are in +> place. + +Here we're going to use dsagt to investigate the stability properties of a tokamak configuration. We'll be looking at linear MHD simulation data produced by the [M3D-C1](https://sites.google.com/pppl.gov/m3d-c1) unstructured-mesh finite-element code. The session below has been tested with Claude Code. ## Dependencies @@ -40,26 +46,12 @@ Now create a new dsagt project and associated directory, here called `fusion-use dsagt init fusion-use-case --agent claude --location ~/data/ ``` -Here we're using Claude Code; see the dsagt [documentation](https://ai-modcon.github.io/dsagt/) for guidelines for using other agents. The created project directory will be a subdirectory of `~/data/`; omitting this with place your project directories in `~/dsagt-projects/`. - -We can start MLflow in the background: - -``` -dsagt mlflow fusion-use-case -``` - -This isn't strictly necessary but will allow us to more accurately recreate our work in a shell script later on. Copy and paste the MLflow export block printed by this command into this current shell. For example the block may be: +Here we're using Claude Code; see the dsagt [documentation](https://ai-modcon.github.io/dsagt/) for guidelines for using other agents. The created project directory will be a subdirectory of `~/data/`; omitting this will place your project directories in `~/dsagt-projects/`. -``` -export CLAUDE_CODE_ENABLE_TELEMETRY=1 -export CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1 -export OTEL_LOG_TOOL_DETAILS=1 -export OTEL_LOG_USER_PROMPTS=1 -export OTEL_TRACES_EXPORTER=otlp -export OTEL_LOG_RAW_API_BODIES=file:/path/to/data/fusion-use-case/api_bodies -``` - -Now enter the project directory and start the agent: +Observability is serverless — there is nothing to start. Every code execution +and agent turn is logged automatically to a SQLite MLflow store at +`~/data/fusion-use-case/mlflow.db`, which we'll use to reconstruct the session +later. Now enter the project directory and start the agent: ``` cd ~/data/fusion-use-case @@ -71,11 +63,11 @@ claude Inside the agent, enter these prompts one at a time, replacing the placeholder directory paths with the corresponding paths on your system: -- There are three python files in the `/path/to/your/dsagt/use_cases/tokamak_stability` directory containing functions for dealing with HDF5 datasets produced by the M3D-C1 code: `hdf5.py`, `m3dc1_tools.py`, and `m3dc1_plots.py`. Register these functions as tools and print a summary here. Read the `AGENTS.md` file in that directory first. +- There are three python files in the `/path/to/your/dsagt/use_cases/tokamak_stability` directory containing functions for dealing with HDF5 datasets produced by the M3D-C1 code: `hdf5.py`, `m3dc1_tools.py`, and `m3dc1_plots.py`. Register these functions as codes and print a summary here. Read the `AGENTS.md` file in that directory first. -Creating and registering the tools may take several minutes. +Creating and registering the codes may take several minutes. -- Using your tools, tell me about the data in the `/path/to/your/m3dc1_data` directory. +- Using your codes, tell me about the data in the `/path/to/your/m3dc1_data` directory. - What are the Miller parameters for this configuration? @@ -93,14 +85,13 @@ New data products will go to a `processed_data/` subdirectory of your project di - Extract the electron temperature and electron density data at t=1 and place them in an HDF5 file `electrons.h5`. -- Using the MLflow traces in the `mlflow/` directory, create a shell script that recreates this session's tool calls on a general data directory that is set at the top of the script. Save as `dsagt_session_script.sh`. +- Using the MLflow traces in the `mlflow.db` store, create a shell script that recreates this session's code executions on a general data directory that is set at the top of the script. Save as `dsagt_session_script.sh`. +(You can browse those traces any time with +`mlflow ui --backend-store-uri sqlite:///~/data/fusion-use-case/mlflow.db`.) -Now exit the agent session as usual and stop the MLflow server: - -``` -dsagt stop fusion-use-case -``` +Now exit the agent session as usual (there is no server to stop — the store is +serverless). The project name (here "fusion-use-case") is registered in `~/dsagt-projects/projects.yaml` (default location). You can unregister the project name with diff --git a/use_cases/tokamak_stability/skills/m3dc1-skill/SKILL.md b/use_cases/tokamak_stability/skills/m3dc1-skill/SKILL.md index dbd6e05..67cd089 100644 --- a/use_cases/tokamak_stability/skills/m3dc1-skill/SKILL.md +++ b/use_cases/tokamak_stability/skills/m3dc1-skill/SKILL.md @@ -1,17 +1,17 @@ --- name: m3dc1-skill -description: Use this skill when working with the python modules `m3dc1_tools.py`, `m3dc1_plots.py`, `hdf5.py` and the tools created from the functions within. Triggers when working with M3D-C1 simulation data or repackaging general HDF5 files. +description: Use this skill when working with the python modules `m3dc1_tools.py`, `m3dc1_plots.py`, `hdf5.py` and the codes created from the functions within. Triggers when working with M3D-C1 simulation data or repackaging general HDF5 files. --- # m3dc1-skill -## Creating CLI tools from M3D-C1 python modules +## Creating CLI codes from M3D-C1 python modules -Always read the relevant API guide before creating CLI tools --- see the `Reference material` section below. +Always read the relevant API guide before creating CLI codes --- see the `Reference material` section below. -IMPORTANT NOTE FOR AGENTS GENERATING NEW TOOLS FROM m3dc1_tools.py +IMPORTANT NOTE FOR AGENTS GENERATING NEW CODES FROM m3dc1_tools.py ------------------------------------------------------------------ The functions marked "requires m3dc1 + fpy" in the initial comment in `m3dc1_tools.py` call into compiled C/Fortran code that writes diagnostic @@ -19,14 +19,14 @@ messages (e.g. "deleting simulation object", "period = 6.28318") directly to the OS-level stdout file descriptor. This output bypasses Python's sys.stdout entirely and cannot be suppressed or redirected from Python. -Consequence: any CLI tool that (1) calls one of these functions and (2) prints +Consequence: any CLI code that (1) calls one of these functions and (2) prints its JSON result to stdout will produce contaminated output that cannot be parsed as JSON when captured via shell redirect. -Required pattern for any new CLI tool wrapping these functions: +Required pattern for any new CLI code wrapping these functions: - Accept an --output-json FILE argument. - Write the JSON result to that file instead of printing to stdout. - - Document in the tool spec that the agent MUST use --output-json and read + - Document in the code spec that the agent MUST use --output-json and read from the file rather than capturing stdout. Functions that ARE affected: @@ -40,18 +40,18 @@ Functions NOT affected (h5py / numpy only — no compiled stdout writes): `compute_ke_growth_trace`, `compute_growth_rate`, `compute_q95` -## When to use these tools +## When to use these codes -Always use the M3D-C1 tools when processing or investigating datasets created by this code. Only create custom tools when your needs cannot be met by any of the existing tools. Be watchful for synonyms of alternative expressions from those used in the tool descriptions; for example, use of `repackage_hdf5` should also be triggered by calls to repack or reorganize HDF5 data, or to create a new file to hold a new dataset etc. +Always use the M3D-C1 codes when processing or investigating datasets created by this code. Only create custom codes when your needs cannot be met by any of the existing codes. Be watchful for synonyms of alternative expressions from those used in the tool descriptions; for example, use of `repackage_hdf5` should also be triggered by calls to repack or reorganize HDF5 data, or to create a new file to hold a new dataset etc. -When using any tools that write a temporary JSON file (to circumvent the corruption of stdout by fpy as described above) that temporary file should be deleted as soon as it is no longer needed to connect tool calls. Do not leave unnecessary JSON files in the filesystem. +When using any codes that write a temporary JSON file (to circumvent the corruption of stdout by fpy as described above) that temporary file should be deleted as soon as it is no longer needed to connect code calls. Do not leave unnecessary JSON files in the filesystem. -If the user's meaning or intent is unclear always ask a clarifying question --- do not silently fail or say that a required tool does not exist. +If the user's meaning or intent is unclear always ask a clarifying question --- do not silently fail or say that a required code does not exist. ## Note on how M3D-C1 field data is stored -M3D-C1 stores fields using the coefficients of the basis functions rather than the pointwise field values themselves. The field values can be found using the `eval_field` function in the `m3dc1` python module; the `eval_field_on_grid` function in `m3dc1_tools.py` evaluates field values across a spatial grid and either returns a dict or writes an HDF5 file. Use these functions or tools derived from them to evaluate fields. If the user asks for field data (e.g. temperature, density, pressure, magnetic field or current density components) to be repackaged, repacked, extracted, moved, rewritten etc. assume that they are interested in these real evaluated field values rather than the raw coefficient values. Only repackage raw coefficients if the user specifically requests this. If the user's intent is unclear always ask a clarifying question. +M3D-C1 stores fields using the coefficients of the basis functions rather than the pointwise field values themselves. The field values can be found using the `eval_field` function in the `m3dc1` python module; the `eval_field_on_grid` function in `m3dc1_tools.py` evaluates field values across a spatial grid and either returns a dict or writes an HDF5 file. Use these functions or codes derived from them to evaluate fields. If the user asks for field data (e.g. temperature, density, pressure, magnetic field or current density components) to be repackaged, repacked, extracted, moved, rewritten etc. assume that they are interested in these real evaluated field values rather than the raw coefficient values. Only repackage raw coefficients if the user specifically requests this. If the user's intent is unclear always ask a clarifying question. ## Where to store products @@ -68,7 +68,7 @@ When asked to make a shell script always check the user's default shell. Create ## Reference material -ALWAYS read the relevant API guide before attempting to create CLI tools from one of the python modules. +ALWAYS read the relevant API guide before attempting to create CLI codes from one of the python modules. - `references/m3dc1_output_guide.md`: explains M3D-C1's native output format in detail, including how the field coefficients are stored and evaluated. From 8ce66247145a1da5617de7ead452287755b1212f Mon Sep 17 00:00:00 2001 From: aarontuor Date: Thu, 2 Jul 2026 15:41:53 -0700 Subject: [PATCH 36/44] docs(use_cases): remove redundant microbial_isolates/demoplan.md A 12-line plan outline fully superseded by isolate_demo.md (same walkthrough, more detail). Referenced nowhere in README/docs. Co-Authored-By: Claude Fable 5 --- use_cases/microbial_isolates/demoplan.md | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 use_cases/microbial_isolates/demoplan.md diff --git a/use_cases/microbial_isolates/demoplan.md b/use_cases/microbial_isolates/demoplan.md deleted file mode 100644 index bb76fd1..0000000 --- a/use_cases/microbial_isolates/demoplan.md +++ /dev/null @@ -1,12 +0,0 @@ -# Demo Plan - -1. Initialize project: `dsagt init isolate-pipeline --agent claude` -2. Configure `dsagt_config.yaml` with API keys and embedding endpoint -3. Start session: `dsagt start isolate-pipeline` -4. Ask agent to load fastp and megahit docs into knowledge base -5. Ask agent to register fastp and megahit tools (conda bin paths) -6. Ask agent to design pipeline for preprocessing and assembling short read sequence data — give one file as example -7. After pipeline design, ask agent to run on all files in the directory -8. Ask agent to search for and use the datacard-generator skill -9. Ask agent to reconstruct the pipeline from execution records -10. Show artifacts: knowledge base collection, processed data, datacard, registered tools, pipeline script, MLflow traces From 30f8e104d238dffbcceff8310d69665c44022bf4 Mon Sep 17 00:00:00 2001 From: aarontuor Date: Thu, 2 Jul 2026 15:53:32 -0700 Subject: [PATCH 37/44] docs: code terminology on the use-case site pages; drop stale isolate transcript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/use-cases/*: 'tool registration' → 'code registration' (index synced with README), '**Tools:**' header labels → '**Codes:**', and the vasp/cryoem overviews reworded (registers codes, not tools; ISAAC record not database). - Removed use_cases/microbial_isolates/isolate_session.txt — a personal scratch transcript with hardcoded paths and the dead two-server launch commands, referenced nowhere; same class as the demoplan.md already removed. Co-Authored-By: Claude Fable 5 --- docs/use-cases/aidrin-cryoem.md | 2 +- docs/use-cases/aidrin-tour.md | 2 +- docs/use-cases/cryoem.md | 2 +- docs/use-cases/index.md | 2 +- docs/use-cases/microbial-isolates.md | 2 +- docs/use-cases/vasp.md | 4 +-- .../microbial_isolates/isolate_session.txt | 27 ------------------- 7 files changed, 7 insertions(+), 34 deletions(-) delete mode 100644 use_cases/microbial_isolates/isolate_session.txt diff --git a/docs/use-cases/aidrin-cryoem.md b/docs/use-cases/aidrin-cryoem.md index 6853cdd..1114366 100644 --- a/docs/use-cases/aidrin-cryoem.md +++ b/docs/use-cases/aidrin-cryoem.md @@ -2,7 +2,7 @@ **Domain:** AI data readiness — quality assessment of a scientific pipeline -**Tools:** [`aidrin`](https://github.com/idtlab/AIDRIN) (AI Data Readiness Inspector CLI) +**Codes:** [`aidrin`](https://github.com/idtlab/AIDRIN) (AI Data Readiness Inspector CLI) **Dataset:** EMPIAR-10017 cryo-EM particle tables (via CryoPPP) diff --git a/docs/use-cases/aidrin-tour.md b/docs/use-cases/aidrin-tour.md index 75263cb..b5810ef 100644 --- a/docs/use-cases/aidrin-tour.md +++ b/docs/use-cases/aidrin-tour.md @@ -2,7 +2,7 @@ **Domain:** AI data readiness — quality, fairness, and privacy assessment -**Tools:** [`aidrin`](https://github.com/idtlab/AIDRIN) (AI Data Readiness Inspector CLI) +**Codes:** [`aidrin`](https://github.com/idtlab/AIDRIN) (AI Data Readiness Inspector CLI) **Dataset:** UCI Adult census (bundled with AIDRIN) diff --git a/docs/use-cases/cryoem.md b/docs/use-cases/cryoem.md index f07226e..8c97d2e 100644 --- a/docs/use-cases/cryoem.md +++ b/docs/use-cases/cryoem.md @@ -8,7 +8,7 @@ ## Overview -This use case demonstrates DSAgt-assisted curation of cryo-EM data from the EMPIAR public archive. The agent registers curation tools, ingests domain knowledge about cryo-EM data quality, and builds a pipeline for micrograph preprocessing. +This use case demonstrates DSAgt-assisted curation of cryo-EM data from the EMPIAR public archive. The agent registers curation codes, ingests domain knowledge about cryo-EM data quality, and builds a pipeline for micrograph preprocessing. ## Guides diff --git a/docs/use-cases/index.md b/docs/use-cases/index.md index e1d73a6..221b44c 100644 --- a/docs/use-cases/index.md +++ b/docs/use-cases/index.md @@ -1,6 +1,6 @@ # Use Cases -End-to-end walkthroughs for representative scientific and data-readiness scenarios live in [`use_cases/`](https://github.com/AI-ModCon/dsagt/tree/main/use_cases/). Each covers data acquisition, tool registration, pipeline construction, and agent-driven execution against a real dataset. +End-to-end walkthroughs for representative scientific and data-readiness scenarios live in [`use_cases/`](https://github.com/AI-ModCon/dsagt/tree/main/use_cases/). Each covers data acquisition, code registration, pipeline construction, and agent-driven execution against a real dataset. | Use case | Domain | Guide | |----------|--------|-------| diff --git a/docs/use-cases/microbial-isolates.md b/docs/use-cases/microbial-isolates.md index b6757dc..78effa5 100644 --- a/docs/use-cases/microbial-isolates.md +++ b/docs/use-cases/microbial-isolates.md @@ -2,7 +2,7 @@ **Domain:** Genomics — short-read QC and assembly -**Tools:** `fastp`, `megahit` +**Codes:** `fastp`, `megahit` **Source:** [`use_cases/microbial_isolates/`](https://github.com/AI-ModCon/dsagt/tree/main/use_cases/microbial_isolates/) diff --git a/docs/use-cases/vasp.md b/docs/use-cases/vasp.md index 9f4ce28..06dd4d5 100644 --- a/docs/use-cases/vasp.md +++ b/docs/use-cases/vasp.md @@ -2,13 +2,13 @@ **Domain:** Materials science — density functional theory -**Tools:** VASP, ISAAC +**Codes:** VASP, ISAAC **Source:** [`use_cases/isaac_vasp/`](https://github.com/AI-ModCon/dsagt/tree/main/use_cases/isaac_vasp/) ## Overview -This use case covers DFT input/output handling with VASP using DSAgt and the ISAAC workflow system. The agent registers VASP pre/post-processing tools and transfers NEB calculation results into the ISAAC database. +This use case covers DFT input/output handling with VASP using DSAgt and the ISAAC workflow system. The agent registers a VASP-to-ISAAC converter as a code and turns NEB calculation results into an ISAAC AI-Ready Record. ## Guides diff --git a/use_cases/microbial_isolates/isolate_session.txt b/use_cases/microbial_isolates/isolate_session.txt deleted file mode 100644 index 810b53d..0000000 --- a/use_cases/microbial_isolates/isolate_session.txt +++ /dev/null @@ -1,27 +0,0 @@ -goose session \ - --with-extension 'uv run dsagt-registry-server' \ - --with-extension 'uv run dsagt-knowledge-server' - -I'd like to create a new collection in the knowledge base: microbial_isolates. -The collection will contain -1) the code package files for fastp: /Users/tuor472/Documents/dsagt_knowledge_complete/repos/fastp/ -2) the code package files for megahit: /Users/tuor472/Documents/dsagt_knowledge_complete/repos/megahit/ and -3) a short document describing a processing pipeline: /Users/tuor472/Documents/dsagt_knowledge_complete/DSAGT/demo/genomics.md -4) A document describing best practices for using fastp and -megahit: /Users/tuor472/Documents/dsagt_knowledge_complete/DSAGT/demo/fastp_megahit_best_practices.md - -Let's add /Users/tuor472/miniconda3/envs/isolate/bin/fastp to the registry -Let's add /Users/tuor472/miniconda3/envs/isolate/bin/megahit to the registry - -Okay good I have an isolate file located at -/Users/tuor472/Documents/dsagt_knowledge_complete/data/microbial_isolate/53162.2.609630.AAAGGCTAGA-GATTCAGTTA.filter-ISO.fastq.gz -Information about the dataset is contained in the Readme in that directory. I need to preprocess this file and assemble it. -fastp and megahit both have data assessment capability so we don't need to create additional tools. -megahit should be run with kmax=21 and memory=0.3 to avoid OOM on this laptop - Tell me your plan before proceeding. - -Let's run this same pipeline the rest of the fastq files at -/Users/tuor472/Documents/dsagt_knowledge_complete/data/microbial_isolate/ -We can process them one at a time. We don't need to create a script for batch processing - -Use the skill located at /Users/tuor472/.config/goose/skills/datacard-generator/ to create a datacard for the processed microbial isolate data From 0463ac1409428bd423c0a948c92d016562942ce3 Mon Sep 17 00:00:00 2001 From: aarontuor Date: Thu, 2 Jul 2026 21:16:06 -0700 Subject: [PATCH 38/44] docs: replace csvkit quickstart demo with a stdlib csv_summary code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit csvkit was dropped as a dependency, but the README/docs quickstart still told users to register csvkit codes (csvcut/csvstat/...) — following it failed at that step. Replace with a bundled, stdlib-only fixture (tests/smoke_test/csv_summary.py: csv + statistics, no install) that the agent registers and runs on samples.csv — the same self-contained pattern the smoke test uses for greet.py. Its null-count output surfaces the empty status/timestamp cells, which is the exact fact the quickstart's explicit-memory step stores and recalls (previously asserted out of nowhere). - README.md + docs/quickstart.md: steps 2 & 4 + capability tables. - docs/provenance.md: the worked example spec and 'Try it' flow, and the example frontmatter updated to the current executable/parameters shape. - tests/test_csv_summary_fixture.py: deterministic guard on the fixture's columns / null-columns / numeric-stats so it can't drift from the docs. Co-Authored-By: Claude Fable 5 --- README.md | 10 ++-- docs/provenance.md | 30 ++++++---- docs/quickstart.md | 11 ++-- tests/smoke_test/csv_summary.py | 92 +++++++++++++++++++++++++++++++ tests/test_csv_summary_fixture.py | 44 +++++++++++++++ 5 files changed, 166 insertions(+), 21 deletions(-) create mode 100644 tests/smoke_test/csv_summary.py create mode 100644 tests/test_csv_summary_fixture.py diff --git a/README.md b/README.md index ee2a437..3d50785 100644 --- a/README.md +++ b/README.md @@ -82,9 +82,9 @@ dsagt start quickstart # …or: cd ~/dsagt-projects/quickstart && Inside the agent, paste these prompts one at a time (substitute the absolute path you exported as `$SMOKE_DIR` — the chat doesn't expand env vars): 1. > Ingest the docs in `$SMOKE_DIR/knowledge/` into a collection named `knowledge`. -2. > Register the csvkit CLI codes `csvcut`, `csvgrep`, `csvstat`, and `csvlook`. +2. > Register the CLI utility at `$SMOKE_DIR/csv_summary.py` as a code named `csv-summary` so we can reuse it. 3. > Use the `scan-directory` code from the registry to scan `$SMOKE_DIR/data/`. -4. > Summarize `samples.csv` — columns, row count, quality issues using csvkit codes from the registry. +4. > Run the `csv-summary` code on `$SMOKE_DIR/data/samples.csv` and tell me the columns, row count, and any columns with null values. 5. > Put this in explicit memory: samples.csv has null values in the status and timestamp columns. 6. > Tell me what you remember about the samples dataset. @@ -93,10 +93,12 @@ This exercised: | Prompt | Capability | |---|---| | 1 | `dsagt-server` (`kb_ingest`) — chunks and indexes docs into ChromaDB | -| 2 | `dsagt-server` (`save_code_spec`) — writes `codes/csvcut/SKILL.md`, `codes/csvgrep/SKILL.md`, etc. (one skill-standard dir per registered code) | -| 3 | `dsagt-run` provenance wrapper — records the execution to `trace_archive/` | +| 2 | `dsagt-server` (`save_code_spec`) — writes `codes/csv-summary/SKILL.md` (a skill-standard dir), wrapping the executable with `dsagt-run` | +| 3–4 | `dsagt-run` provenance wrapper — records each execution to `trace_archive/` | | 5–6 | Explicit memory (`kb_remember` → `.dsagt/explicit_memories.yaml`) + KB recall (`kb_get_memories`) | +(`csv_summary.py` is stdlib-only — no dependency to install — so registration and execution work out of the box. Step 4's null-column finding is the fact you store and recall in 5–6.) + Exit the agent (`Ctrl+C` or `/exit`), then verify the artifacts and view traces: ```bash diff --git a/docs/provenance.md b/docs/provenance.md index c55cef9..52bbf54 100644 --- a/docs/provenance.md +++ b/docs/provenance.md @@ -8,22 +8,27 @@ Codes are CLI executables defined as markdown files with YAML frontmatter under A code spec includes: -- A YAML frontmatter block describing the command, arguments, dependencies, and tags. -- A markdown body with usage examples and notes for the agent. +- A YAML frontmatter block describing the executable, parameters, dependencies, and tags. +- A markdown body with the exact runnable command, usage, and notes for the agent. -Example code spec: +Example code spec (`codes/csv-summary/SKILL.md`): ```markdown --- -name: csvstat -command: csvstat +name: csv-summary +description: Summarize a CSV — columns, row count, null counts, numeric stats. Use when profiling a tabular dataset. +executable: dsagt-run --code csv-summary -- python codes/csv-summary/scripts/csv_summary.py +parameters: + file: + type: string + required: true + cli: positional + description: Path to the CSV file dependencies: [] -tags: [csv, statistics] +tags: [csv, profiling] --- -Prints descriptive statistics for all columns in a CSV file. - -Usage: csvstat [options] [FILE] +Run this registered code with the exact shell command below… ``` DSAgt wraps every registered code with `dsagt-run` for provenance capture and `uv run --with` for Python dependencies, so the agent can call any code without managing environments manually. It ships one bundled code, `scan-directory`, indexed for search by `dsagt init`. @@ -41,14 +46,15 @@ The on-disk execution records are the canonical provenance chain. The agent call ## Try it ```bash +export SMOKE_DIR="$(pwd)/tests/smoke_test" # a small bundled sample script + CSV dsagt init # follow the prompts: name it `demo`, then pick your agent dsagt start demo # launch the agent in the project ``` -Then, in the agent: +Then, in the agent (replace `$SMOKE_DIR` with the absolute path you exported): -1. > Register the csvkit codes `csvstat` and `csvcut`. -2. > Use `csvstat` from the registry on `data/samples.csv` and summarize the columns. +1. > Register the CLI utility at `$SMOKE_DIR/csv_summary.py` as a code named `csv-summary`. +2. > Run the `csv-summary` code from the registry on `$SMOKE_DIR/data/samples.csv` and summarize the columns. 3. > Reconstruct the pipeline as a bash script. Afterwards, inspect the trail: diff --git a/docs/quickstart.md b/docs/quickstart.md index 39af7f0..f3020d3 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -27,20 +27,21 @@ dsagt start quickstart # …or: cd ~/dsagt-projects/quickstart && Ingest the docs in `$SMOKE_DIR/knowledge/` into a collection named `knowledge`. -2. > Register the csvkit CLI codes `csvcut`, `csvgrep`, `csvstat`, and `csvlook`. +2. > Register the CLI utility at `$SMOKE_DIR/csv_summary.py` as a code named `csv-summary` so we can reuse it. 3. > Use the `scan-directory` code from the registry to scan `$SMOKE_DIR/data/`. -4. > Summarize `samples.csv` — columns, row count, quality issues using csvkit codes from the registry. +4. > Run the `csv-summary` code on `$SMOKE_DIR/data/samples.csv` and tell me the columns, row count, and any columns with null values. 5. > Put this in explicit memory: samples.csv has null values in the status and timestamp columns. 6. > Tell me what you remember about the samples dataset. +`csv_summary.py` is stdlib-only, so there's no dependency to install — registration and execution work out of the box. Step 4's null-column finding is the fact you store and recall in 5–6. + ## Capabilities Covered | Prompt | DSAgt capability | |--------|-------------| | 1 | `dsagt-server` (`kb_ingest`) — chunks and indexes docs into ChromaDB | -| 2 | `dsagt-server` (`save_code_spec`) — writes `codes/csvcut.md`, etc. | -| 3 | `dsagt-run` provenance wrapper — records the execution to `trace_archive/` | -| 4 | KB recall via `kb_search` and registered code execution | +| 2 | `dsagt-server` (`save_code_spec`) — writes `codes/csv-summary/SKILL.md` (a skill-standard dir), wrapping the executable with `dsagt-run` | +| 3–4 | `dsagt-run` provenance wrapper — records each registered-code execution to `trace_archive/` | | 5–6 | Explicit memory (`kb_remember` → `.dsagt/explicit_memories.yaml`) + `kb_get_memories` | ## Verify the Artifacts diff --git a/tests/smoke_test/csv_summary.py b/tests/smoke_test/csv_summary.py new file mode 100644 index 0000000..bcb8916 --- /dev/null +++ b/tests/smoke_test/csv_summary.py @@ -0,0 +1,92 @@ +"""csv_summary — the quickstart's registerable, executable fixture code. + +Stdlib-only (``csv`` + ``statistics``), so registering and running it via +dsagt-run needs no dependency install — the dependency-free replacement for +the csvkit demo. Summarizes a CSV: column list, row count, per-column null +count, and min/max/mean for numeric columns. On ``samples.csv`` the null +counts surface the empty ``status`` / ``timestamp`` cells, which is the fact +the quickstart's explicit-memory step stores and recalls. +""" + +import argparse +import csv +import json +import statistics +import sys + + +def _as_number(value): + if value is None or value.strip() == "": + return None + try: + return float(value) + except ValueError: + return None + + +def summarize(path): + with open(path, newline="") as fh: + reader = csv.DictReader(fh) + columns = reader.fieldnames or [] + rows = list(reader) + + nulls = {c: 0 for c in columns} + numeric = {c: [] for c in columns} + for row in rows: + for c in columns: + v = row.get(c) + if v is None or v.strip() == "": + nulls[c] += 1 + else: + n = _as_number(v) + if n is not None: + numeric[c].append(n) + + # A column is "numeric" only if every non-null value parsed as a number. + numeric_stats = {} + for c in columns: + vals = numeric[c] + non_null = len(rows) - nulls[c] + if vals and len(vals) == non_null: + numeric_stats[c] = { + "min": min(vals), + "max": max(vals), + "mean": round(statistics.fmean(vals), 4), + } + + return { + "file": path, + "columns": columns, + "row_count": len(rows), + "null_counts": nulls, + "columns_with_nulls": [c for c in columns if nulls[c] > 0], + "numeric_stats": numeric_stats, + } + + +def main(): + parser = argparse.ArgumentParser( + description="Summarize a CSV: columns, row count, null counts, numeric stats." + ) + parser.add_argument("file", help="Path to the CSV file") + args = parser.parse_args() + + try: + result = summarize(args.file) + except FileNotFoundError: + print( + json.dumps( + { + "status": "error", + "code": "CSV-404", + "error": f"no such file: {args.file}", + } + ) + ) + sys.exit(1) + + print(json.dumps(result, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/tests/test_csv_summary_fixture.py b/tests/test_csv_summary_fixture.py new file mode 100644 index 0000000..4b67238 --- /dev/null +++ b/tests/test_csv_summary_fixture.py @@ -0,0 +1,44 @@ +"""Guard the quickstart's bundled csv_summary fixture. + +The README/docs quickstart has the agent register and run +``tests/smoke_test/csv_summary.py`` on ``samples.csv``; step 4's +null-column finding is the fact the explicit-memory step (5-6) stores and +recalls. This pins that contract so the fixture can't silently drift out +from under the docs. +""" + +import importlib.util +from pathlib import Path + +_FIXTURE = Path(__file__).parent / "smoke_test" / "csv_summary.py" +_SAMPLES = Path(__file__).parent / "smoke_test" / "data" / "samples.csv" + + +def _load(): + spec = importlib.util.spec_from_file_location("csv_summary_fixture", _FIXTURE) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def test_summary_reports_columns_and_row_count(): + result = _load().summarize(str(_SAMPLES)) + assert result["columns"] == ["id", "name", "status", "score", "timestamp"] + assert result["row_count"] == 8 + + +def test_summary_surfaces_the_null_columns_the_memory_step_stores(): + result = _load().summarize(str(_SAMPLES)) + # The quickstart's step 5 remembers "null values in the status and + # timestamp columns" — it must come from here, not thin air. + assert result["columns_with_nulls"] == ["status", "timestamp"] + assert result["null_counts"]["status"] == 1 + assert result["null_counts"]["timestamp"] == 1 + + +def test_summary_computes_numeric_stats_only_for_numeric_columns(): + result = _load().summarize(str(_SAMPLES)) + assert set(result["numeric_stats"]) == {"id", "score"} + assert result["numeric_stats"]["score"]["max"] == 92.1 + # timestamp is date-like, not numeric — must not appear. + assert "timestamp" not in result["numeric_stats"] From 0743207e13a75f10751b92dad4cd7a8d65293d73 Mon Sep 17 00:00:00 2001 From: aarontuor Date: Thu, 2 Jul 2026 22:36:44 -0700 Subject: [PATCH 39/44] fix(observability): tag background trace spans + add dsagt traces viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP-server traces had two hygiene problems (analyzed from a real store): 1. Orphaned background spans. The dispatch shell tags each tool-call root dsagt.source=, but off-thread work (the heartbeat's CodeUseIndexer, the startup catch-up) ran outside any trace, so its kb.add_entries/kb.embed opened as untagged top-level traces — landing in the 'unknown' bucket and detached from the executions they index. Fix: CodeUseIndexer.tick_traced() wraps indexing in a dsagt.source=code_use root on the worker thread; heartbeat + startup catch-up use it (raw tick() stays for reconstruct_pipeline, which already runs under a registry root). 2. Episodic noise. Per-turn memory.extract shared dsagt.source=memory with the user-facing kb_remember/kb_get_memories. Retagged dsagt.source=episodic so internal embedding filters apart. dsagt info: recognizes episodic/code_use; adds an Agent turns vs Internal/debug headline split (unknown counts as internal, not agent). New: dsagt traces — a frictionless MLflow viewer. Runs catch-up first (flushes the deferred last turn an ungraceful exit leaves unlogged), deep-links to the experiment's Traces tab (DSAGT writes traces not runs, so the default Runs view looks empty), and launches foreground + quiet (--workers 1, PYTHONWARNINGS=ignore) — no managed daemon, so the store stays serverless and there's no dsagt stop to add. Verified end-to-end: a fresh smoke run's store has 0 untagged orphans (was 2) with code_use + episodic present and every trace attributable. Suite: 617 passed (+9). Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 +- src/dsagt/commands/cli.py | 22 +++++++ src/dsagt/commands/info.py | 39 +++++++++++-- src/dsagt/commands/traces.py | 103 +++++++++++++++++++++++++++++++++ src/dsagt/mcp/server.py | 5 +- src/dsagt/memory.py | 7 ++- src/dsagt/provenance.py | 25 +++++++- src/dsagt/session.py | 5 +- tests/test_info.py | 82 ++++++++++++++++++++++++++ tests/test_memory_extractor.py | 27 +++++++++ tests/test_tool_executions.py | 30 ++++++++++ tests/test_traces_command.py | 77 ++++++++++++++++++++++++ 12 files changed, 413 insertions(+), 11 deletions(-) create mode 100644 src/dsagt/commands/traces.py create mode 100644 tests/test_traces_command.py diff --git a/CLAUDE.md b/CLAUDE.md index ec949dc..eb7bb51 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ uv run ruff check . # lint The codebase separates **commands** (entry points with argparse, launched as CLI tools or subprocesses) from **modules** (importable logic). Commands live in `src/dsagt/commands/`, modules in `src/dsagt/`. **Commands** (`src/dsagt/commands/`): -- `cli.py` — `dsagt init / start / info / list / mv / rm / smoke-test`. `dsagt start` launches the agent in the foreground and runs post-session extraction on exit. +- `cli.py` — `dsagt init / start / info / traces / list / mv / rm / smoke-test`. `dsagt start` launches the agent in the foreground and runs post-session extraction on exit. `dsagt traces ` opens the MLflow viewer over the project's store (runs catch-up first, deep-links to the Traces tab, quiets the mlflow noise; foreground, no managed daemon). - `run_code.py` — `dsagt-run` (code execution wrapper). - `setup_core_kb.py` — KB-asset build engine (`resolve_assets` / `ensure_assets`), called by `dsagt init` to provision the shared KB; not a CLI command of its own. - `info.py` — `dsagt info` (project/config introspection + trace triage). diff --git a/src/dsagt/commands/cli.py b/src/dsagt/commands/cli.py index b79d7b9..f39ec77 100644 --- a/src/dsagt/commands/cli.py +++ b/src/dsagt/commands/cli.py @@ -20,6 +20,7 @@ [--include ... | --exclude ...] # non-interactive dsagt start dsagt info [--json] + dsagt traces [--port ] dsagt smoke-test dsagt list dsagt mv @@ -552,6 +553,13 @@ def _cmd_info(args): return run(args.project, as_json=args.json) +def _cmd_traces(args): + """Open the MLflow trace viewer over the project's store (catch-up first).""" + from dsagt.commands.traces import run + + return run(args.project, port=args.port) + + def _cmd_smoke_test(args): """Run the end-to-end smoke test (non-interactive, with assertions). @@ -722,6 +730,19 @@ def main(argv=None): help="Emit the structured report as JSON instead of formatted text", ) + p_traces = sub.add_parser( + "traces", + help="Open the MLflow trace viewer over a project's store (runs catch-up " + "first, deep-links to the Traces tab, quiets the mlflow noise)", + ) + p_traces.add_argument("project", help="Project name") + p_traces.add_argument( + "--port", + type=int, + default=5000, + help="Port for the local MLflow UI (default: 5000)", + ) + p_smoke = sub.add_parser( "smoke-test", help="Run the end-to-end smoke test (sources DSAGT/.env, drives the agent non-interactively, asserts artifacts)", @@ -784,6 +805,7 @@ def main(argv=None): "init": _cmd_init, "start": _cmd_start, "info": _cmd_info, + "traces": _cmd_traces, "smoke-test": _cmd_smoke_test, "list": _cmd_list, "mv": _cmd_mv, diff --git a/src/dsagt/commands/info.py b/src/dsagt/commands/info.py index 4eea9d5..80f04fc 100644 --- a/src/dsagt/commands/info.py +++ b/src/dsagt/commands/info.py @@ -19,8 +19,14 @@ traces, from the ``dsagt.source`` tag (the MCP tool category the agent invoked, set on the trace root by the dispatch shell). - ``execution`` — dsagt-run tool-execute traces (``dsagt.source``). + - ``episodic`` / ``code_use`` — background emitters (per-turn memory + extraction; trace-archive indexing), tagged at their off-thread call site + so their embedding writes don't orphan as untagged ``unknown`` roots. - ``claude`` / ``goose`` / ``cline`` / ``codex`` — agent traces, from the ``dsagt.agent`` metadata stamped by ``MLflowSink`` (the bulk of traffic). + +The ``Agent turns`` / ``Internal/debug`` headline splits the recovered agent +conversation from all of DSAGT's own bookkeeping traces (:data:`_INTERNAL_SOURCES`). """ from __future__ import annotations @@ -225,15 +231,26 @@ def _fmt_count(n: int) -> str: return f"{n / 1_000_000:.1f}M" +#: The ``dsagt.source`` categories — internal (debug) traces DSAGT emits, as +#: opposed to the recovered agent-conversation traces. MCP tool categories +#: (``memory`` / ``skill`` / ``knowledge`` / ``registry``), ``execution`` for +#: dsagt-run, plus the two background emitters: ``episodic`` (per-turn memory +#: extraction) and ``code_use`` (the trace-archive indexer). +_INTERNAL_SOURCES = frozenset( + {"memory", "skill", "knowledge", "registry", "execution", "episodic", "code_use"} +) + + def _row_source_for(tags: dict, metadata: dict) -> str: """Bucket a trace by who emitted it, from the metadata DSAGT itself stamps. No span inspection: internal debug traces carry an explicit ``dsagt.source`` - tag (the MCP tool category — ``memory`` / ``skill`` / ``knowledge`` / - ``registry`` — or ``execution`` for dsagt-run), set on the trace root by the - MCP dispatch shell / ``code_execute_span``. Agent traces carry ``dsagt.agent`` - metadata, stamped by ``MLflowSink``. Neither overlaps, so the bucket is a - direct lookup; anything else (a stray trace with neither) is ``"unknown"``. + tag (see :data:`_INTERNAL_SOURCES`), set on the trace root by the MCP + dispatch shell / ``code_execute_span`` / the background emitters. Agent + traces carry ``dsagt.agent`` metadata, stamped by ``MLflowSink``. Neither + overlaps, so the bucket is a direct lookup; anything else (a stray trace + with neither — which should no longer happen now background work is tagged) + is ``"unknown"``. """ src = (tags or {}).get("dsagt.source") if src: @@ -557,6 +574,18 @@ def _print_text(r: dict) -> None: f"{_fmt_count(r['output_tokens'])} out" ) print(f" Errors: {r['total_errors']}") + # Split agent turns (the substance) from DSAGT's own internal/debug traces + # (embedding, indexing, tool spans) so the headline isn't dominated by + # bookkeeping. ``unknown`` counts as internal/debug — it's an orphaned + # DSAGT span, not an agent turn (and should be empty now background work is + # tagged); only genuine agent-conversation traces count as agent turns. + agent_traces = sum( + row["traces"] + for row in r["by_source"] + if row["source"] not in _INTERNAL_SOURCES and row["source"] != "unknown" + ) + internal_traces = r["total_traces"] - agent_traces + print(f" Agent turns: {agent_traces} Internal/debug: {internal_traces}") print() print("By source:") diff --git a/src/dsagt/commands/traces.py b/src/dsagt/commands/traces.py new file mode 100644 index 0000000..e5eb6fd --- /dev/null +++ b/src/dsagt/commands/traces.py @@ -0,0 +1,103 @@ +""" +dsagt traces — open the MLflow trace viewer over the project's +serverless store, frictionlessly. + +Three ergonomic wins over a raw +``mlflow ui --backend-store-uri sqlite:////mlflow.db``: + +1. **Catch-up first.** Runs :func:`dsagt.session.catch_up_extraction`, which + flushes the most-recent session's deferred final turn (the one an ungraceful + agent exit leaves unlogged) into the store — so "I don't see my last query" + is fixed before the viewer even opens. +2. **Deep link to the Traces tab.** DSAGT writes MLflow *traces*, not classic + runs, so the default Experiments/Runs view looks empty. We resolve the + project's experiment id and print the URL that lands directly on its Traces + tab. +3. **Quiet.** ``--workers 1`` and ``PYTHONWARNINGS=ignore`` drop the repeated + Starlette deprecation spam; the viewer runs in the foreground (Ctrl-C to + stop), so there is no background server to hunt down and kill later — the + store stays serverless. +""" + +from __future__ import annotations + +import logging +import os +import subprocess +from pathlib import Path + +from dsagt.session import catch_up_extraction, load_config + +logger = logging.getLogger(__name__) + +_DEFAULT_PORT = 5000 + + +def _resolve_experiment_id(tracking_uri: str, project: str) -> str | None: + """The MLflow experiment id for *project*, or None if not yet created.""" + try: + import mlflow + + mlflow.set_tracking_uri(tracking_uri) + exp = mlflow.get_experiment_by_name(project) + return exp.experiment_id if exp else None + except Exception as e: # noqa: BLE001 — a missing id only costs the deep link + logger.debug("Could not resolve experiment id for %s: %s", project, e) + return None + + +def run(project: str, port: int = _DEFAULT_PORT) -> int: + config = load_config(project) + pdir = Path(config["project_dir"]) + db = pdir / "mlflow.db" + if not db.exists(): + print( + f"No trace store yet for '{project}' ({db} not found). " + "Run a session first: dsagt start " + f"{project}" + ) + return 1 + + # 1. Catch-up: surface the most-recent session's deferred final turn before + # the viewer opens. Best-effort — a viewer must open even if catch-up + # hiccups. + try: + result = catch_up_extraction(pdir, config) + caught = result.get("traces_caught_up", 0) + if caught: + print(f"Caught up {caught} trailing trace(s) from the last session.") + except Exception as e: # noqa: BLE001 + logger.warning("Trace catch-up before viewer failed: %s", e) + + # 2. Deep-link to the project's Traces tab (DSAGT emits traces, not runs, so + # the default Runs view looks empty). + tracking_uri = f"sqlite:///{db}" + exp_id = _resolve_experiment_id(tracking_uri, project) + base = f"http://127.0.0.1:{port}" + url = f"{base}/#/experiments/{exp_id}/traces" if exp_id else base + + print(f"\nMLflow trace view for '{project}':\n {url}") + print("(Ctrl-C to stop the viewer — the store itself is serverless.)\n") + + # 3. Foreground, quiet: one worker + warnings off drops the Starlette noise. + env = {**os.environ, "PYTHONWARNINGS": "ignore"} + cmd = [ + "mlflow", + "ui", + "--backend-store-uri", + tracking_uri, + "--port", + str(port), + "--workers", + "1", + ] + try: + return subprocess.run(cmd, env=env).returncode + except FileNotFoundError: + print( + "mlflow not found on PATH. It ships with dsagt — activate the same " + "environment dsagt runs in." + ) + return 1 + except KeyboardInterrupt: + return 0 diff --git a/src/dsagt/mcp/server.py b/src/dsagt/mcp/server.py index 0f1f477..e010140 100644 --- a/src/dsagt/mcp/server.py +++ b/src/dsagt/mcp/server.py @@ -148,7 +148,10 @@ async def _heartbeat(collector, tool_indexer, interval: float, project_dir) -> N logger.debug("Could not record trace source: %s", e) if tool_indexer is not None: try: - n = await asyncio.to_thread(tool_indexer.tick) + # tick_traced (not tick): opens a code_use categorization root on + # the worker thread so the indexer's kb.* writes nest under it + # instead of orphaning as untagged top-level traces. + n = await asyncio.to_thread(tool_indexer.tick_traced) if n: logger.info("Tool-use heartbeat: indexed %d record(s)", n) except Exception as e: # noqa: BLE001 diff --git a/src/dsagt/memory.py b/src/dsagt/memory.py index 19c5ec9..e2d249f 100644 --- a/src/dsagt/memory.py +++ b/src/dsagt/memory.py @@ -373,12 +373,15 @@ def write(self, trace: "Trace") -> None: if not exchanges: return # Categorization root: this runs on the heartbeat, outside any MCP - # dispatch, so tag the whole extraction ``dsagt.source=memory`` — the + # dispatch, so tag the whole extraction ``dsagt.source=episodic`` — the # nested kb.* writes inherit it (otherwise they'd land uncategorized). + # ``episodic``, NOT ``memory``: this is per-turn internal embedding, and + # must filter apart from the user-facing memory tools (kb_remember / + # kb_get_memories) that carry ``dsagt.source=memory``. # (This tags the *observability* span, not the memory chunks.) from dsagt.observability import open_span - with open_span("memory.extract", source="memory"): + with open_span("memory.extract", source="episodic"): self._index_turns(exchanges) def _index_turns(self, exchanges: list[dict]) -> None: diff --git a/src/dsagt/provenance.py b/src/dsagt/provenance.py index 5e58ea9..9abe20c 100644 --- a/src/dsagt/provenance.py +++ b/src/dsagt/provenance.py @@ -382,7 +382,14 @@ def _lock(self): fcntl.flock(lf, fcntl.LOCK_UN) def tick(self) -> int: - """Index newly-arrived records; return how many were indexed this tick.""" + """Index newly-arrived records; return how many were indexed this tick. + + Emits no categorization root of its own: at the ``reconstruct_pipeline`` + call site this runs *inside* the registry tool's trace, so its ``kb.*`` + writes correctly inherit ``dsagt.source=registry``. The background + callers (heartbeat / startup catch-up) run outside any trace and use + :meth:`tick_traced` so their writes don't orphan as untagged roots. + """ with self._lock(): acks = self._load_acks() before = len(acks) @@ -393,6 +400,22 @@ def tick(self) -> int: self._save_acks(acks) return result.get("indexed", 0) + def tick_traced(self) -> int: + """:meth:`tick` under a ``dsagt.source=code_use`` categorization root. + + For the background triggers (heartbeat, startup catch-up) that run off + any tool-call trace — otherwise the indexer's ``kb.add_entries`` / + ``kb.embed`` spans start their own untagged top-level traces, landing in + the ``unknown`` bucket and detached from the executions they index. + Must run on the same thread as the embedding (open the span inside the + worker), so callers dispatch *this* to the thread, not a wrapped + :meth:`tick`. + """ + from dsagt.observability import open_span + + with open_span("code_use.index", source="code_use"): + return self.tick() + # --------------------------------------------------------------------------- # Pipeline reconstruction diff --git a/src/dsagt/session.py b/src/dsagt/session.py index 4b9a634..e1d630d 100644 --- a/src/dsagt/session.py +++ b/src/dsagt/session.py @@ -746,7 +746,10 @@ def catch_up_extraction(pdir: Path, config: dict) -> dict: try: tool_use_indexed = 0 try: - tool_use_indexed = CodeUseIndexer(kb, pdir).tick() + # tick_traced: this catch-up runs off any tool-call trace, so tag + # the indexer's kb.* writes dsagt.source=code_use rather than let + # them orphan as untagged top-level traces. + tool_use_indexed = CodeUseIndexer(kb, pdir).tick_traced() except Exception as e: # noqa: BLE001 — never let a background task crash logger.warning("Tool execution indexing failed: %s", e) diff --git a/tests/test_info.py b/tests/test_info.py index 93a54eb..fba023b 100644 --- a/tests/test_info.py +++ b/tests/test_info.py @@ -219,6 +219,88 @@ def test_report_aggregates_by_source_and_session(config): assert err["trace_id"] == "t4" +def test_episodic_and_code_use_bucket_as_internal_sources(config): + """The background emitters carry their own dsagt.source and are counted as + internal/debug, not agent turns.""" + from dsagt.commands.info import _INTERNAL_SOURCES + + assert {"episodic", "code_use"} <= _INTERNAL_SOURCES + + df = _traces_df( + [ + { + "trace_id": "a1", + "state": "OK", + "request_time": 100, + "trace_metadata": _metadata( + session="s", agent="claude", in_t=900, out_t=90 + ), + "tags": _tags_for(None), + }, + { + "trace_id": "e1", + "state": "OK", + "request_time": 110, + "trace_metadata": _metadata(session="s", agent=None, in_t=0, out_t=0), + "tags": _tags_for("episodic"), + }, + { + "trace_id": "c1", + "state": "OK", + "request_time": 120, + "trace_metadata": _metadata(session="s", agent=None, in_t=0, out_t=0), + "tags": _tags_for("code_use"), + }, + ] + ) + r = _report("proj", config, df) + sources = {row["source"]: row for row in r["by_source"]} + assert sources["episodic"]["traces"] == 1 + assert sources["code_use"]["traces"] == 1 + # Agent-vs-internal split (the headline): 1 agent turn, 2 internal. + agent_traces = sum( + row["traces"] + for row in r["by_source"] + if row["source"] not in _INTERNAL_SOURCES and row["source"] != "unknown" + ) + assert agent_traces == 1 + assert r["total_traces"] - agent_traces == 2 + + +def test_agent_vs_internal_split_counts_unknown_as_internal(config): + """An orphaned/uncategorized trace (no source, no agent) is bookkeeping — + it counts as internal/debug, never as an agent turn.""" + from dsagt.commands.info import _INTERNAL_SOURCES + + df = _traces_df( + [ + { + "trace_id": "a1", + "state": "OK", + "request_time": 100, + "trace_metadata": _metadata( + session="s", agent="claude", in_t=10, out_t=1 + ), + "tags": _tags_for(None), + }, + { # neither dsagt.source nor dsagt.agent → "unknown" + "trace_id": "u1", + "state": "OK", + "request_time": 110, + "trace_metadata": _metadata(session="s", agent=None, in_t=0, out_t=0), + "tags": _tags_for(None), + }, + ] + ) + r = _report("proj", config, df) + agent_traces = sum( + row["traces"] + for row in r["by_source"] + if row["source"] not in _INTERNAL_SOURCES and row["source"] != "unknown" + ) + assert agent_traces == 1 # not 2 — the unknown is internal/debug + + def test_report_missing_source_falls_back_to_unknown(config): """No dsagt.source tag and no dsagt.agent → bucket is "unknown".""" df = _traces_df( diff --git a/tests/test_memory_extractor.py b/tests/test_memory_extractor.py index a4ce3c7..7015035 100644 --- a/tests/test_memory_extractor.py +++ b/tests/test_memory_extractor.py @@ -107,3 +107,30 @@ def test_empty_trace_writes_nothing(tmp_path): ext = MemoryExtractor(kb, runtime_dir=tmp_path, session_id="proj:s") ext.write(Trace("t", "proj:s", "claude", "proj")) assert kb.calls == [] + + +def test_extraction_span_tagged_episodic_not_memory(tmp_path): + """The per-turn embedding runs off the heartbeat, so it must carry + dsagt.source=episodic — filtering apart from the user-facing memory tools + (kb_remember / kb_get_memories) that carry dsagt.source=memory.""" + from unittest.mock import patch + + opened = {} + + class _Span: + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def _fake_open_span(name, span_type=None, source=None): + opened["source"] = source + return _Span() + + kb = FakeKB() + ext = MemoryExtractor(kb, runtime_dir=tmp_path, session_id="proj:s") + with patch("dsagt.observability.open_span", _fake_open_span): + ext.write(_one_turn_trace()) + + assert opened["source"] == "episodic" diff --git a/tests/test_tool_executions.py b/tests/test_tool_executions.py index 0c1707b..2e60c67 100644 --- a/tests/test_tool_executions.py +++ b/tests/test_tool_executions.py @@ -201,6 +201,36 @@ def test_incremental_and_idempotent(self, tmp_path): assert set(acks) == {"r1", "r2"} kb.close() + def test_tick_traced_wraps_in_code_use_source_span(self, tmp_path): + """The background triggers use tick_traced so the indexer's kb.* writes + nest under a dsagt.source=code_use root instead of orphaning.""" + pdir = tmp_path / "proj" + (pdir / ".dsagt").mkdir(parents=True) + kb = MagicMock() + indexer = CodeUseIndexer(kb, pdir) + + opened = {} + + class _Span: + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def _fake_open_span(name, span_type=None, source=None): + opened["name"] = name + opened["source"] = source + return _Span() + + with patch("dsagt.observability.open_span", _fake_open_span): + # No records → tick returns 0, but the span must still be opened + # with the right source. + assert indexer.tick_traced() == 0 + + assert opened["source"] == "code_use" + assert opened["name"] == "code_use.index" + # --------------------------------------------------------------------------- # index_trace_archive diff --git a/tests/test_traces_command.py b/tests/test_traces_command.py new file mode 100644 index 0000000..c8e475c --- /dev/null +++ b/tests/test_traces_command.py @@ -0,0 +1,77 @@ +"""Tests for ``dsagt traces`` — the frictionless MLflow viewer launcher.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from dsagt.commands import traces as traces_cmd + + +def _config(pdir): + return {"project": "proj", "project_dir": str(pdir), "agent": "claude"} + + +def test_missing_store_returns_error(tmp_path): + pdir = tmp_path / "proj" + pdir.mkdir() + with patch.object(traces_cmd, "load_config", return_value=_config(pdir)): + rc = traces_cmd.run("proj") + assert rc == 1 # no mlflow.db yet + + +def test_runs_catchup_then_launches_deeplinked_quiet_viewer(tmp_path, capsys): + pdir = tmp_path / "proj" + pdir.mkdir() + (pdir / "mlflow.db").write_text("") # existence is all run() checks + + launched = {} + + def _fake_subprocess_run(cmd, env=None): + launched["cmd"] = cmd + launched["env"] = env + return MagicMock(returncode=0) + + with ( + patch.object(traces_cmd, "load_config", return_value=_config(pdir)), + patch.object( + traces_cmd, "catch_up_extraction", return_value={"traces_caught_up": 2} + ) as catchup, + patch.object(traces_cmd, "_resolve_experiment_id", return_value="42"), + patch.object(traces_cmd.subprocess, "run", _fake_subprocess_run), + ): + rc = traces_cmd.run("proj", port=5001) + + assert rc == 0 + catchup.assert_called_once() # catch-up ran before the viewer + + cmd = launched["cmd"] + assert cmd[:2] == ["mlflow", "ui"] + assert "--workers" in cmd and cmd[cmd.index("--workers") + 1] == "1" + assert cmd[cmd.index("--port") + 1] == "5001" + assert cmd[cmd.index("--backend-store-uri") + 1].startswith("sqlite:///") + # Quiet: warnings suppressed in the child env. + assert launched["env"]["PYTHONWARNINGS"] == "ignore" + + out = capsys.readouterr().out + # Deep-links to the project's Traces tab (not the empty default Runs view). + assert "http://127.0.0.1:5001/#/experiments/42/traces" in out + assert "Caught up 2" in out + + +def test_launch_survives_catchup_failure(tmp_path): + pdir = tmp_path / "proj" + pdir.mkdir() + (pdir / "mlflow.db").write_text("") + + with ( + patch.object(traces_cmd, "load_config", return_value=_config(pdir)), + patch.object( + traces_cmd, "catch_up_extraction", side_effect=RuntimeError("boom") + ), + patch.object(traces_cmd, "_resolve_experiment_id", return_value=None), + patch.object( + traces_cmd.subprocess, "run", return_value=MagicMock(returncode=0) + ), + ): + # A hiccup in catch-up must not stop the viewer from opening. + assert traces_cmd.run("proj") == 0 From f4fe4f6b4e01ec169c952eb441cb58953c74d47a Mon Sep 17 00:00:00 2001 From: aarontuor Date: Mon, 6 Jul 2026 09:25:45 -0700 Subject: [PATCH 40/44] feat(skills): mirror skills/codes natively at install time; temper restart messaging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit install_skill / save_skill / save_code_spec now run the same idempotent native-skills mirror that init/start run (agents.refresh_native_skills), so .claude/skills/ is current the moment the files land — the next session auto-discovers them however the agent is launched, with no dependency on `dsagt start` (whose mirror remains as a backstop for hand-dropped skill dirs). Reword every message the agent takes its framing from (tool descriptions, dsagt_instructions.md, skill-creator's confirm step) to present installs as complete and immediately usable — the agent reads the SKILL.md this session (all native invocation does) and must not tell the user a restart is needed. Mid-session native *listing* remains agent-side behavior (platforms enumerate skills at session startup). Also drops a stale "no API key" clause from the README embedder line. Co-Authored-By: Claude Fable 5 --- README.md | 4 +-- docs/skills.md | 2 +- src/dsagt/agents/__init__.py | 28 +++++++++++++++++ src/dsagt/commands/cli.py | 9 +++--- src/dsagt/dsagt_instructions.md | 4 +-- src/dsagt/mcp/registry_tools.py | 9 ++++-- src/dsagt/mcp/skill_tools.py | 42 +++++++++++++++---------- src/dsagt/skills/skill-creator/SKILL.md | 4 +-- tests/test_skill_tools.py | 39 +++++++++++++++++++++++ 9 files changed, 112 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 3d50785..656aaf4 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ The same flow runs non-interactively via `dsagt smoke-test --agent claude` (or ` - **Skill Catalogs** — the skill-catalog sources you pick at init (default `genesis`) are cloned and indexed so `search_skills` returns installable skills. The bundled `skill-creator` is discovered natively by the agent. - **Knowledge Collections** — optional reference document sets you pick at init (`nemo_curator`, `aidrin`), downloaded and indexed for data-curation domain knowledge. -The default embedder is a local sentence-transformers model (~130 MB of weights downloaded on first run, CPU-side, no API key). +The default embedder is a local sentence-transformers model (~130 MB of weights downloaded on first run). ## Use Case Examples @@ -173,7 +173,7 @@ DSAGT exposes a single MCP server, **`dsagt-server`**, that an agent connects to **Skills** are instruction-based agent workflows — a directory with a `SKILL.md` and optional reference docs. They come in two tiers: -- **Installed** skills live in `/skills/` (DSAgt ships a bundled `skill-creator`; domain skills like the MODCON datacard generator are installed from the `genesis` catalog). These are mirrored into the agent's native skill directory (e.g. `.claude/skills/`, `.agents/skills/`) at `dsagt init`/`start`, where the agent auto-discovers and auto-invokes them — no `search_skills` needed (that covers only the catalog tier below). +- **Installed** skills live in `/skills/` (DSAgt ships a bundled `skill-creator`; domain skills like the MODCON datacard generator are installed from the `genesis` catalog). These are mirrored into the agent's native skill directory (e.g. `.claude/skills/`, `.agents/skills/`) at install time (and re-mirrored at `dsagt init`/`start`), where the agent auto-discovers and auto-invokes them — no `search_skills` needed (that covers only the catalog tier below). - **Catalog** skills come from external Git repositories — GitHub *or* GitLab — indexed into a searchable catalog the agent browses with `search_skills` but that is **not** loaded into its context (so a catalog can hold thousands of skills). The agent enables a source with `add_skill_source(...)`, finds skills with `search_skills(...)`, then copies one into the project with `install_skill(...)`. The catalog is **opt-in**: a source must be synced before its skills are searchable. Curated named sources ship out of the box — `k-dense-ai`, `anthropic`, `antigravity`, `composio`, and `genesis` (the OSTI GENESIS catalog: HPC, HuggingFace, LangChain, OpenAI, plasma-sim, and more) — and any Git URL or `owner/repo` works too. Manage catalogs from the agent with `list_skill_sources` / `add_skill_source` / `search_skills` / `install_skill`. diff --git a/docs/skills.md b/docs/skills.md index f0bff24..23a6cd7 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -11,7 +11,7 @@ Skills live in `/skills/`. Each is a directory containing a `SKILL.md` Skills live in **two tiers**, and a single MCP service — the **SkillRouter** — is the one entry point that routes every skill operation between them: - **Catalog tier** — skills that exist in external repositories but are *not yet installed*. DSAgt federates many sources (`k-dense-ai`, `anthropic`, `antigravity`, `composio`, `genesis`, or any git URL); each is cloned and indexed into its own collection. The agent browses this tier with `search_skills` and manages sources with `add_skill_source` / `list_skill_sources`. -- **Installed + created tier** — skills drawn into the project's Skill Directory (`/skills/`), either installed from the catalog (`install_skill`) or authored in place (e.g. with the bundled `skill-creator`). At `dsagt start` these are mirrored into each agent's *native* skill directory (`.claude/`, `.agents/`, `.cline/`), where the agent auto-discovers and auto-invokes them. +- **Installed + created tier** — skills drawn into the project's Skill Directory (`/skills/`), either installed from the catalog (`install_skill`) or authored in place (e.g. with the bundled `skill-creator`). These are mirrored into each agent's *native* skill directory (`.claude/`, `.agents/`, `.cline/`) at install time (and re-mirrored at `dsagt init`/`start`), where the agent auto-discovers and auto-invokes them. The lifecycle runs **Discovery** (the router) → **Registration** (the searchable catalog) → **Progressive Exposure** (the native Skill Directory the agent loads on its own). diff --git a/src/dsagt/agents/__init__.py b/src/dsagt/agents/__init__.py index 94ac45f..4899ee8 100644 --- a/src/dsagt/agents/__init__.py +++ b/src/dsagt/agents/__init__.py @@ -25,6 +25,7 @@ - :func:`static_agent_record` — write instructions + state dirs. - :func:`static_agent_files_present` — has the static record been written? - :func:`dynamic_agent_record` — write runtime-dependent files. +- :func:`refresh_native_skills` — re-run the native-skills mirror on demand. - :func:`launch_agent` — fork the agent and block until exit. """ @@ -174,6 +175,33 @@ def dynamic_agent_record( return actions +def refresh_native_skills(working_dir: str | Path) -> list[str]: + """Re-run the native-skills mirror for the project's configured agent. + + Called by the MCP tools right after a skill is installed/created or a + code is registered, so the native skills dir is current the moment the + files land — the next session auto-discovers them no matter how the agent + is launched (bare or ``dsagt start``). An already-running session + enumerates its skills at startup (agent-side behavior), but can use a + fresh skill right away by reading its SKILL.md — which is all native + invocation does. Idempotent (manifest-tracked, the same mirror + :func:`dynamic_agent_record` runs at init/start). + + No-op when ``working_dir`` has no ``.dsagt/config.yaml`` agent — the dir + hasn't been ``dsagt init``-ed, so there is no native skills dir to mirror + into (the test-facing ``create_*_server`` wrappers over bare tmp dirs). + """ + # Lazy: session drags in knowledge/provenance at module level, which this + # package (imported by the CLI at cold start) must not pay for. + from dsagt.session import read_config_file + + working_dir = Path(working_dir) + config = read_config_file(working_dir) + if not config.get("agent"): + return [] + return _setup_for(config["agent"]).setup_skills(working_dir, config) + + def launch_agent( config: dict, env: dict, diff --git a/src/dsagt/commands/cli.py b/src/dsagt/commands/cli.py index f39ec77..c0cd975 100644 --- a/src/dsagt/commands/cli.py +++ b/src/dsagt/commands/cli.py @@ -408,10 +408,11 @@ def _cmd_start(args): """Refresh the dynamic agent record, then ``cd && ``. The refresh (``dynamic_agent_record``: per-agent MCP config + - native-skills mirror, all idempotent) is what makes ``install_skill`` / - ``save_code_spec`` results appear natively "after the next dsagt start", - and lets a credential-dependent step skipped at init (cline auth) pick - up once the vars are in the shell. Session lifecycle (session-id + native-skills mirror, all idempotent) backstops the install-time mirror + the MCP tools run (``agents.refresh_native_skills``) — e.g. a skill dir + dropped in by hand still mirrors here — and lets a credential-dependent + step skipped at init (cline auth) pick up once the vars are in the + shell. Session lifecycle (session-id minting, post-session extraction catch-up) is owned by the MCP server at startup. diff --git a/src/dsagt/dsagt_instructions.md b/src/dsagt/dsagt_instructions.md index 0208c23..acd634e 100644 --- a/src/dsagt/dsagt_instructions.md +++ b/src/dsagt/dsagt_instructions.md @@ -27,14 +27,14 @@ Before implementing anything, search for existing capabilities: **Skills come in two tiers.** *Installed* skills (in this project) are discovered **natively** by your platform — their names/descriptions are already in your context and you auto-invoke them; you do NOT need `search_skills` to find those. Separately there is a much larger *external catalog* of installable skills (entries marked `[catalog]`), NOT loaded into context. -**Registered codes also appear among your native skills** (mirrored at `dsagt start`). A code's SKILL.md is an execution instruction: it opens with the exact shell command to run. When you invoke a code from its native skill entry, copy that command byte-for-byte — the same verbatim-`executable` rule as section 1b. +**Registered codes also appear among your native skills** (mirrored at registration and at `dsagt start`). A code's SKILL.md is an execution instruction: it opens with the exact shell command to run. When you invoke a code from its native skill entry, copy that command byte-for-byte — the same verbatim-`executable` rule as section 1b. **The external catalog is opt-in and starts empty — sources must be synced before `search_skills` can see them.** A blank/weak `search_skills` result usually means the relevant source isn't synced yet, NOT that no such skill exists. So before concluding the catalog has nothing, call `list_skill_sources()` — it reports each known source with its `synced` flag and `indexed` count. The flow: 1. `list_skill_sources()` — see which sources are already synced vs only `available` (known name + URL, not yet indexed). For materials/chem/bio/DFT skills, the `k-dense-ai` source is the one to enable. 2. `add_skill_source(source=...)` — sync a source (a known name like `scientific`/`anthropic`, or a GitHub URL). Read-only indexing step; nothing is installed into the project. Only needed for sources whose `synced` is false. 3. `search_skills(query)` — now browse the synced catalog. Entries marked `[catalog]` are installable. -4. `install_skill(skill_name=...)` — copy a catalog skill into the project. Its SKILL.md + scripts land on disk immediately, so you can **use it this session** by reading `skills//SKILL.md` and following it. A restart (next `dsagt start`) is only needed for hands-free *native* auto-invocation, not for use. +4. `install_skill(skill_name=...)` — copy a catalog skill into the project. The install is **complete and immediately usable**: its SKILL.md + scripts land in `skills//` and are mirrored into your native skills dir on the spot. To use it this session, read `skills//SKILL.md` and follow it — that is exactly what native invocation does. Future sessions auto-discover it hands-free. Never tell the user a restart or any other action is needed before an installed skill can be used. To author a brand-new skill instead of installing one, use the bundled `skill-creator` skill. diff --git a/src/dsagt/mcp/registry_tools.py b/src/dsagt/mcp/registry_tools.py index 790e889..f26a9fb 100644 --- a/src/dsagt/mcp/registry_tools.py +++ b/src/dsagt/mcp/registry_tools.py @@ -148,6 +148,11 @@ async def _handle_save_code_spec( obs.event("save_tool_failed", error=str(e)[:256]) return f"Error saving tool spec: {e}" + # Codes share the skill envelope, so a fresh registration mirrors into + # the agent's native skills dir right away (next session discovers it). + from dsagt.agents import refresh_native_skills + + refresh_native_skills(registry.runtime_dir) tool_count = len(registry.list_codes_raw()) obs.set("action", action) obs.set("registry_size", tool_count) @@ -391,8 +396,8 @@ def _registry_tools_and_handlers( name="save_code_spec", description=( "Register a CLI code: writes a skill-standard spec dir " - "(codes//SKILL.md) the agent also auto-discovers " - "natively after the next dsagt start" + "(codes//SKILL.md), mirrored into the agent's native " + "skills dir immediately so future sessions auto-discover it" ), inputSchema={ "type": "object", diff --git a/src/dsagt/mcp/skill_tools.py b/src/dsagt/mcp/skill_tools.py index 56ba75e..4a8bd7e 100644 --- a/src/dsagt/mcp/skill_tools.py +++ b/src/dsagt/mcp/skill_tools.py @@ -38,10 +38,11 @@ async def _handle_save_skill( ) -> str: """Register a skill (workflow / agent instructions) for later reuse. - Writes SKILL.md to ``/skills//``, where every supported - agent natively auto-discovers it (after the next ``dsagt start`` mirror). - No KB indexing — ``search_skills`` covers only the not-yet-installed - *catalog* tier, since installed skills are already natively discoverable. + Writes SKILL.md to ``/skills//`` and mirrors it into the + agent's native skills dir immediately, where every supported agent + auto-discovers it from its next session. No KB indexing — + ``search_skills`` covers only the not-yet-installed *catalog* tier, since + installed skills are already natively discoverable. """ spec = arguments["spec"] if isinstance(spec, str): @@ -62,6 +63,9 @@ async def _handle_save_skill( ) except (KeyError, ValueError, OSError) as e: return f"Error saving skill: {e}" + from dsagt.agents import refresh_native_skills + + refresh_native_skills(skill_registry.runtime_dir) skill_count = len(skill_registry.list_skills()) return ( f"Skill '{spec['name']}' {action} successfully. " @@ -96,10 +100,10 @@ async def _handle_install_skill( ) -> str: """Install a catalog skill into ``/skills//``. - The skill's files land on disk immediately, so the agent can use it in the - current session by reading its SKILL.md. *Native* auto-invocation requires - the next ``dsagt start`` (which mirrors installed skills into - ``.claude/skills/`` before launch) plus an agent restart. + The skill's files land on disk and are mirrored into the agent's native + skills dir immediately — usable right away (the agent reads/follows its + SKILL.md, which is all native invocation does); hands-free auto-discovery + kicks in at the agent's next session, with no user action. """ from dsagt.skills import SkillRouter @@ -110,8 +114,11 @@ async def _handle_install_skill( info = SkillRouter().install(name, runtime_dir) except LookupError as e: return f"Error: {e}" + from dsagt.agents import refresh_native_skills + + refresh_native_skills(runtime_dir) - # Bare confirmation by design: the install→use→restart model and the + # Bare confirmation by design: the install→use model and the # license/PROVENANCE capture are already in the agent's instructions and on # disk (PROVENANCE.txt), so repeating them on every install is just noise. verb = "Updated" if info["action"] == "updated" else "Installed" @@ -241,11 +248,12 @@ def _skill_tools_and_handlers( name="save_skill", description=( "Register a skill (agent workflow / instructions) into " - "/skills//SKILL.md, where the agent natively " - "auto-discovers it after the next `dsagt start`. Symmetric " - "with save_code_spec — use this when you've designed a " - "reusable instruction set you want future sessions to load " - "automatically." + "/skills//SKILL.md, mirrored into the agent's " + "native skills dir immediately so future sessions " + "auto-discover it — no restart or user action needed. " + "Symmetric with save_code_spec — use this when you've " + "designed a reusable instruction set you want future " + "sessions to load automatically." ), inputSchema={ "type": "object", @@ -353,8 +361,10 @@ def _skill_tools_and_handlers( name="install_skill", description=( "Install a skill from the external catalog (found via search_skills) " - "into this project so the agent can use it natively. Copies SKILL.md " - "+ scripts/references; available natively after the next restart." + "into this project. Copies SKILL.md + scripts/references and mirrors " + "it into the agent's native skills dir — usable immediately (read and " + "follow its SKILL.md); future sessions auto-discover it natively with " + "no user action." ), inputSchema={ "type": "object", diff --git a/src/dsagt/skills/skill-creator/SKILL.md b/src/dsagt/skills/skill-creator/SKILL.md index c4978b9..00f93f6 100644 --- a/src/dsagt/skills/skill-creator/SKILL.md +++ b/src/dsagt/skills/skill-creator/SKILL.md @@ -58,8 +58,8 @@ Confirm before saving: ### 6. Save -Save via the **`save_skill`** MCP tool with the `spec` (frontmatter dict: `name`, `description`, optional `tags`), the `body` markdown, and any `reference_files` (a `{relative_path: contents}` map). This writes `/skills//`, which the agent natively auto-discovers after the next `dsagt start` (no KB indexing — `search_skills` is for the not-yet-installed catalog). +Save via the **`save_skill`** MCP tool with the `spec` (frontmatter dict: `name`, `description`, optional `tags`), the `body` markdown, and any `reference_files` (a `{relative_path: contents}` map). This writes `/skills//` and mirrors it into the platform's native skill directory (e.g. `.claude/skills/`) immediately (no KB indexing — `search_skills` is for the not-yet-installed catalog). ### 7. Confirm -Tell the user the skill was saved and how it activates: project skills are mirrored into the platform's native skill directory (e.g. `.claude/skills/`) at the next `dsagt start`, after which the agent auto-discovers it. To use it in the current session, restart the agent. +Tell the user the skill was saved and is ready to use. Future sessions auto-discover it natively; to apply it in the current session, read `skills//SKILL.md` and follow it — that is all native invocation does. Do not tell the user a restart or any other action is needed. diff --git a/tests/test_skill_tools.py b/tests/test_skill_tools.py index 25bac9d..9bc0e29 100644 --- a/tests/test_skill_tools.py +++ b/tests/test_skill_tools.py @@ -146,6 +146,45 @@ def test_search_skills_empty_catalog_hints_to_sync(self, tmp_path): assert "add_skill_source" in text +class TestNativeMirror: + + def test_save_skill_mirrors_into_native_skills_dir(self, tmp_path): + """A skill created mid-session mirrors into the agent's native skills + dir immediately (not at the next ``dsagt start``), so the next session + auto-discovers it however the agent is launched. install_skill and + save_code_spec run the same ``refresh_native_skills`` call.""" + server, skill_reg, kb = _make_skill_server(tmp_path) + runtime = tmp_path / "runtime" + (runtime / ".dsagt").mkdir() + (runtime / ".dsagt" / "config.yaml").write_text("project: p\nagent: claude\n") + + call_tool_sync( + server, + "save_skill", + { + "spec": {"name": "mirror-me", "description": "mirrors right away"}, + "body": "# mirror-me\n\nDo the thing.\n", + }, + ) + + native = runtime / ".claude" / "skills" + assert (native / "mirror-me" / "SKILL.md").exists() + manifest = json.loads((native / ".dsagt-managed.json").read_text()) + assert "mirror-me" in manifest + + def test_save_skill_without_project_config_skips_mirror(self, tmp_path): + """No ``.dsagt/config.yaml`` (not dsagt init-ed) → the skill still + saves; no native dir appears.""" + server, skill_reg, kb = _make_skill_server(tmp_path) + text = call_tool_sync( + server, + "save_skill", + {"spec": {"name": "bare", "description": "no config"}, "body": "# b\n"}, + ) + assert "added" in text + assert not (tmp_path / "runtime" / ".claude").exists() + + # --------------------------------------------------------------------------- # install_skill # --------------------------------------------------------------------------- From dd9f861ab1987e42661602285e23aeef413ad229 Mon Sep 17 00:00:00 2001 From: aarontuor Date: Mon, 6 Jul 2026 09:26:01 -0700 Subject: [PATCH 41/44] docs(use_cases): restructure genesis-skills walkthrough MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setup → prompt dialogue → post-conditions: the mid-walkthrough shell checks move to one consolidated post-conditions block, so the user isn't bouncing to a terminal after every step. The two catalog searches merge into one conjoined prompt (verified against the live Genesis catalog: the keyword scorer ranks croissant-validator #1 and generating-datacards #3 of 74, so both surface from a single search). Design-history rationale and over-explaining parentheticals removed. Co-Authored-By: Claude Fable 5 --- use_cases/genesis_skills/README.md | 123 ++++++++--------------------- 1 file changed, 35 insertions(+), 88 deletions(-) diff --git a/use_cases/genesis_skills/README.md b/use_cases/genesis_skills/README.md index 08727db..f52ab62 100644 --- a/use_cases/genesis_skills/README.md +++ b/use_cases/genesis_skills/README.md @@ -28,9 +28,6 @@ runs in seconds with no real instruments or HPC. license from the KB-ingested domain docs — and emits a datacard, then `croissant-validator` checks the Croissant metadata. -The three tiers in one sentence: **catalog = searchable but not in context; -installed = native and auto-invoked; KB = retrievable domain grounding.** - ## Setup Assumes DSAgt (`uv sync --all-groups`) and Claude Code @@ -43,72 +40,49 @@ otherwise (configure it for sharper relevance over the domain docs). ```bash dsagt init genesis-skills --agent claude # provisions the bundled codes/skills + core KB cp -r use_cases/genesis_skills/mock_data ~/dsagt-projects/genesis-skills/mock_data -dsagt start genesis-skills # mirrors skill-creator into .claude/skills/ before launch +dsagt start genesis-skills ``` -The Genesis catalog is **project-scoped** and not synced by `init`, so the agent -enables it in step 1 below (via the `add_skill_source` MCP tool). +The Genesis catalog is not synced at `init` — the agent enables it in step 1. ## Walkthrough -Paste each prompt into Claude Code (running inside the project), one at a time, -and check the expected behavior. +Paste each prompt into Claude Code (running inside the project), one at a time. +Confirmation checks are consolidated in **Post-conditions** below. ### 1 — Enable the Genesis source + > Enable the "genesis" skill source so we have the GENESIS / ModCon data-curation skills available. Then tell me how many skills it indexed. *Expect:* `add_skill_source(source="genesis")` → a shallow clone of OSTI GitLab, -**74** skills indexed into `skills_catalog__genesis-genesis-skills`, source -written to `.dsagt/config.yaml`. (Two upstream skills — including -`datacard-generator` — have technically-invalid YAML frontmatter; dsagt recovers -their name/description with a lenient fallback rather than dropping them, so they -*are* searchable.) Confirm from a shell: - -```bash -dsagt info genesis-skills # the skills_catalog__genesis-genesis-skills collection appears in the KB summary -``` - -### 2 — Find and install the datacard skill (catalog, NOT in context) -Search for the two deliverables **separately** — a single "datacard *and* Croissant" -query lets the validator outrank the generator. First the datacard generator: +**74** skills indexed, source written to `.dsagt/config.yaml`. -> Search the catalog for a skill that creates a datacard / dataset documentation for a dataset, then install the best match into this project. +### 2 — Find and install the curation skills -*Expect:* `search_skills` (catalog hits tagged `[catalog · install_skill to add]`, -**`generating-datacards`** ranked top) → `install_skill(skill_name="generating-datacards")`; -the reply notes it'll be native after the next start and that a `PROVENANCE.txt` -(Genesis source) was written. +> Search the catalog for two skills — one that creates a datacard / dataset documentation for a dataset, and one that validates Croissant / JSON-LD dataset metadata — and install the best match for each into this project. -### 3 — Find and install the Croissant validator -> Now search the catalog for a skill that validates Croissant / JSON-LD dataset metadata, and install the best match. +*Expect:* `search_skills` surfaces **`generating-datacards`** and +**`croissant-validator`** → `install_skill` for each. The reply presents both +as installed and immediately usable (no restart caveat), each with a +`PROVENANCE.txt` crediting the Genesis source. -*Expect:* `search_skills` (**`croissant-validator`** ranked top) → -`install_skill(skill_name="croissant-validator")`. **Verify** both installs (each -lands with any `scripts/`/`references/`): +### 3 — Ingest the domain docs into the KB -```bash -ls ~/dsagt-projects/genesis-skills/skills/ -cat ~/dsagt-projects/genesis-skills/skills/generating-datacards/PROVENANCE.txt -``` - -### 4 — Ingest the domain docs into the KB > Ingest the domain docs under mock_data/domain/ into a new knowledge-base collection called "methanation_domain". Poll until it finishes, then tell me what's in it. *Expect:* `kb_ingest(folder_path="mock_data/domain", collection_name="methanation_domain")` returns a `job_id`; the agent polls `kb_job_status` to completion, then -`kb_list_collections` shows `methanation_domain` (2 docs). **Verify:** +`kb_list_collections` shows `methanation_domain` (2 docs). -```bash -dsagt info genesis-skills # the new collection appears in the KB summary -``` +### 4 — Retrieve domain grounding -### 5 — Retrieve domain grounding > Using the knowledge base, what reactor conditions were used for the CO2 conversion measurement, and what license applies to this dataset? *Expect:* `kb_search` over `methanation_domain` → **250 °C, 1 atm, H2:CO2 = 4:1, -GHSV 12,000**; license **CC-BY-4.0** (pulled from the protocol doc, not guessed). +GHSV 12,000**; license **CC-BY-4.0**. + +### 5 — Generate the datacard for the finished dataset -### 6 — Generate the datacard for the finished dataset > Use the generating-datacards skill to write a datacard for mock_data/dataset/catalyst_screening.csv. Pull the field definitions, measurement methodology, provenance, and license from the methanation_domain knowledge-base collection — don't invent them. Save it to audit/catalyst_screening_datacard.md. Then compare your sections against mock_data/expected_datacard.md and report anything missing. *Expect:* the agent reads the installed skill's `SKILL.md`, queries the KB, @@ -116,60 +90,33 @@ computes basic stats from the 8-row CSV, and writes `audit/catalyst_screening_datacard.md` covering summary / provenance / schema / methodology / stats / limitations / license. -### 7 — Validate the metadata +### 6 — Validate the metadata + > Use the croissant-validator skill to check the Croissant/JSON-LD metadata for this dataset (generate it from the datacard if needed), and report any schema errors. *Expect:* the validator skill runs and reports a clean pass or names specific schema issues. -### 8 — Inspect the tiers (run in a shell) -```bash -dsagt info genesis-skills # KB shows the genesis catalog collection + methanation_domain -ls ~/dsagt-projects/genesis-skills/skills/ # installed: generating-datacards + croissant-validator -ls ~/dsagt-projects/genesis-skills/.claude/skills/ -cat ~/dsagt-projects/genesis-skills/.claude/skills/.dsagt-managed.json -ls ~/dsagt-projects/genesis-skills/audit/ # catalyst_screening_datacard.md -``` +## Post-conditions -Restart Claude (`dsagt start genesis-skills` again) to pick up the installed -Genesis skills as native auto-invoked skills. +Confirm from a shell: -## Post-Conditions +```bash +dsagt info genesis-skills # KB lists skills_catalog__genesis-genesis-skills + methanation_domain +ls ~/dsagt-projects/genesis-skills/skills/ # generating-datacards croissant-validator +ls ~/dsagt-projects/genesis-skills/.claude/skills/ # both mirrored here at install time +cat ~/dsagt-projects/genesis-skills/skills/generating-datacards/PROVENANCE.txt +ls ~/dsagt-projects/genesis-skills/audit/ # catalyst_screening_datacard.md +``` 1. The KB holds a `skills_catalog__genesis-genesis-skills` collection - (searchable via `search_skills`, absent from Claude's context) **and** a - `methanation_domain` document collection (retrievable via `kb_search`). + (searchable via `search_skills`) **and** a `methanation_domain` document + collection (retrievable via `kb_search`). 2. `generating-datacards` and `croissant-validator` are installed into - `/skills/`, mirrored into `.claude/skills/`, each with a - `PROVENANCE.txt` crediting the Genesis source. + `/skills/` and mirrored into `.claude/skills/`, each with a + `PROVENANCE.txt` crediting the Genesis source. The next Claude session + auto-invokes them natively; this session used them by reading their + `SKILL.md`. 3. `audit/catalyst_screening_datacard.md` was produced for the finished dataset, grounded in the KB-ingested domain docs, covering the sections in `mock_data/expected_datacard.md`. -4. The Croissant metadata validates (or its errors are reported). -5. `.claude/skills/.dsagt-managed.json` tracks exactly the dsagt-placed skills. - -## Cleanup - -```bash -dsagt rm genesis-skills # add -y to skip the prompt -``` - -The shared catalog cache lives at `~/dsagt-projects/.skill_sources/` and is -reused across projects; delete it to force a fresh clone. - -## Notes - -- The Genesis catalog is hosted on **OSTI GitLab** (`gitlab.osti.gov`), not - GitHub — reached the same way as any other source (the `add_skill_source` - MCP tool); only the host differs. -- `generating-datacards` is the frontmatter *name* of the skill whose directory - is `datacard-generator` in the Genesis repo — `install_skill` accepts either. - It and `croissant-validator` live under Genesis's `modcon-skills/` category - (the BASE-Data team's own skills), so this demo is DSAgt consuming its sibling - project's curated skills. -- `mock_data/` is intentionally tiny and illustrative. `expected_datacard.md` is - a *shape* to check coverage against, not a byte-for-byte answer — the installed - skill owns the authoritative template. -- Sister demo: [`isaac_skills_demo`](../isaac_skills_demo/) flexes the same - catalog → install → native-mirror loop plus authoring a new skill with - `skill-creator`, against the K-Dense `k-dense-ai` (GitHub) source. From 528b5fc19a618803019c27b4e85eaa85efb58062 Mon Sep 17 00:00:00 2001 From: aarontuor Date: Mon, 6 Jul 2026 09:26:01 -0700 Subject: [PATCH 42/44] feat(cli): unlisted `dsagt mlflow` alias for `dsagt traces` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The word people reach for when they want the MLflow viewer. Rewritten pre-parse (only the command slot — a project named "mlflow" is untouched) so argparse and --help only ever know `traces`; deliberately absent from the docs. Co-Authored-By: Claude Fable 5 --- src/dsagt/commands/cli.py | 12 +++++++++++ tests/test_config.py | 42 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/src/dsagt/commands/cli.py b/src/dsagt/commands/cli.py index c0cd975..e1fa39d 100644 --- a/src/dsagt/commands/cli.py +++ b/src/dsagt/commands/cli.py @@ -645,6 +645,18 @@ def _run_one(agent: str) -> tuple[str, int, float]: def main(argv=None): from dsagt import __version__ + argv = list(sys.argv[1:] if argv is None else argv) + # `dsagt mlflow ` is an unlisted alias for `traces` — the word + # people reach for when they want the MLflow viewer. Rewritten before + # parsing (only the command slot: the first non-flag token) so argparse — + # and therefore --help — only ever knows `traces`. + for i, tok in enumerate(argv): + if tok.startswith("-"): + continue + if tok == "mlflow": + argv[i] = "traces" + break + parser = argparse.ArgumentParser( prog="dsagt", description="DSAgt project and session management." ) diff --git a/tests/test_config.py b/tests/test_config.py index 8b7c1ad..292c8cf 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -257,6 +257,48 @@ def test_no_mlflow_port_pinned(self): assert "mlflow" not in parsed +# --------------------------------------------------------------------------- +# CLI: unlisted `mlflow` alias for `traces` +# --------------------------------------------------------------------------- + + +class TestMlflowAlias: + """``dsagt mlflow`` routes to ``traces`` without ever reaching argparse, + so it stays out of --help; only the command slot is rewritten (a project + may itself be named "mlflow").""" + + def _capture_traces(self, monkeypatch): + from dsagt.commands import cli + + seen = {} + + def fake(args): + seen.update(project=args.project, port=args.port) + return 0 + + monkeypatch.setattr(cli, "_cmd_traces", fake) + return cli, seen + + def test_mlflow_routes_to_traces(self, monkeypatch): + cli, seen = self._capture_traces(monkeypatch) + assert cli.main(["mlflow", "myproj", "--port", "5001"]) == 0 + assert seen == {"project": "myproj", "port": 5001} + + def test_project_named_mlflow_is_not_rewritten(self, monkeypatch): + cli, seen = self._capture_traces(monkeypatch) + assert cli.main(["traces", "mlflow"]) == 0 + assert seen == {"project": "mlflow", "port": 5000} + + def test_alias_absent_from_help_command_list(self, capsys): + from dsagt.commands import cli + + with pytest.raises(SystemExit): + cli.main(["--help"]) + out = capsys.readouterr().out + choices = out[out.index("{") + 1 : out.index("}")] + assert "mlflow" not in choices + + # --------------------------------------------------------------------------- # CLI: init choice resolution (_collect_settings) # --------------------------------------------------------------------------- From a31d1bc8f13a36e2ee33e4df7490b7f2a6f5fe0f Mon Sep 17 00:00:00 2001 From: aarontuor Date: Mon, 6 Jul 2026 09:52:55 -0700 Subject: [PATCH 43/44] docs(changelog): summarize the unreleased cycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Condense [Unreleased] to under a page: keep the trace-pipeline / episodic entries (terminology fixed to code_use / code.execute, which the tools→codes rename missed here), add the work since — dsagt traces, dsagt.source span tagging, the codes rename, skill-envelope codes, install-time native mirroring, pre-authenticated agents, cline/codex fixes, docs restructure, new use cases. Dropped the stale 23→21 tool count. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 89 ++++++++++++++++++++++++++++------------------------ 1 file changed, 48 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea7b32d..6f0f2f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,55 +6,62 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] -This cycle restores **observability and episodic memory without a proxy** — both -recovered by reading each agent's own on-disk session transcript instead of -intercepting its LLM traffic — and ships episodic memory end to end. +This cycle restores **observability and episodic memory without a proxy** — +both recovered from each agent's own on-disk transcript rather than intercepted +LLM traffic — renames registered executables to **codes**, and makes codes and +skills natively discoverable the moment they land in a project. ### Added -- **Trace pipeline + observability (proxy-free).** An in-session heartbeat in - `dsagt-server` reads the agent's transcript, translates it to a canonical - trace shape, and logs it to the serverless MLflow store — so prompts, - responses, tool calls, and token usage land in the trace view for **every** - supported agent (claude, codex, goose, opencode, cline), with no proxy, no - OTel routing, and no credentials. DSAgt's own `kb.*` / `tool.execute` / - registry spans flow to the same store. -- **Episodic memory (opt-in).** `dsagt init --episodic` turns on automatic - session memory: the heartbeat mechanically chunks, keyword-tags (against a - closed "AI-data-ready" taxonomy), and embeds each completed turn into the - per-project `session_memory` collection — no LLM, no credentials — - retrievable via `kb_search` / `kb_get_memories`. -- **Recency-weighted episodic retrieval** (`episodic.recency_half_life_days`, - default 14): a newer turn edges out a stale one as a bounded boost, so a more - recent turn wins without contradiction detection while durable old turns keep - their relevance. +- **Proxy-free trace pipeline.** An in-session heartbeat in `dsagt-server` + reads the agent's transcript and logs prompts, responses, tool calls, and + token usage to the serverless MLflow store — every supported agent (claude, + codex, goose, opencode, cline); no proxy, no OTel routing, no credentials. + DSAgt's own spans flow to the same store, tagged `dsagt.source` so the debug + view filters apart from agent traces. +- **`dsagt traces `** opens the MLflow viewer with nothing to + remember: runs trace catch-up first, deep-links straight to the project's + Traces tab, quiets the server noise. +- **Episodic memory (opt-in).** `dsagt init --episodic`: the heartbeat + mechanically chunks, keyword-tags, and embeds each completed turn into + `session_memory` (no LLM); retrieval is recency-weighted + (`episodic.recency_half_life_days`, default 14). +- New domain use cases: tokamak stability, AIDRIN data-readiness. ### Changed -- **Tool-use indexing is now incremental.** `dsagt-run` execution records are - embedded into the `tool_use` collection by the heartbeat as a session runs - (idempotent), and on demand right before `reconstruct_pipeline` — so the - pipeline review and tool-use search reflect the calls just made, instead of - waiting for the next session's startup catch-up. +- **"Tools" are now "codes."** Registered CLI executables are *codes* + throughout — `/codes/`, `save_code_spec`, `dsagt-run --code`, the + `code_use` collection, `code.execute` spans — reserving "tool" for the + MCP/agent sense. +- **Codes share the skill envelope.** A code is a skill-standard dir + (`codes//SKILL.md`); bundled codes are copied into `/codes/` + at init — one place, one format. +- **Native discovery is immediate.** Installing/creating a skill or registering + a code mirrors it into the agent's native skills dir on the spot (previously + at the next `dsagt start`), so the next session auto-discovers it however the + agent is launched — and the agent no longer tells users a restart is needed. +- **Agents are pre-authenticated.** DSAgt never touches provider credentials; + all credential-hint machinery is gone. +- **Code-use indexing is incremental.** `dsagt-run` records embed into + `code_use` on the heartbeat and on demand before `reconstruct_pipeline`, + instead of waiting for the next session's startup catch-up. +- Docs split into per-capability pages; use-case walkthroughs restructured + (setup → prompt dialogue → post-conditions) and swept for staleness. ### Fixed -- **Duplicate `tool_use` entries.** Startup catch-up re-indexed the entire - `trace_archive` every launch (the idempotency cursor was written but never - read), accumulating duplicates each session. Indexing is now idempotent - against a persisted ack set shared by the heartbeat and catch-up. +- **Duplicate `code_use` entries** — indexing is now idempotent against a + persisted ack set shared by the heartbeat and startup catch-up. +- **cline:** per-project MCP config via `CLINE_MCP_SETTINGS_PATH`; `cline mcp + add` works on cline 3.x; global auth and settings are never touched. +- **codex:** the trace reader follows the per-project `CODEX_HOME`. ### Removed -- Dead `provenance.index_execution_record` (the orphaned single-record path, - superseded by the heartbeat's batched, idempotent indexer). -- **Episodic LLM-judge distillation layer** (`judge.py`: `Judge` / `LocalJudge` - / `APIJudge` + `llama-cpp-python`) and the **outlier-suggestion** feature - (`CategoryCentroids` / `SuggestionQueue` + the `kb_get_suggestions` / - `kb_dismiss_suggestion` MCP tools, 23 → 21 tools). Episodic memory keeps only - the mechanical capture path so a Tier-0 baseline can be measured before the - judge's runtime/dependency cost is justified; the design notes and parked code - live in `design-notes/judge.md` and `scratch/`. -- `dsagt init` no longer prompts for an LLM judge or solicits a per-project - memory taxonomy; the `--domain-tags` flag and the `episodic.judge` / - `episodic.domain_tags` / `episodic.outlier_sensitivity` config keys are gone. - The stock taxonomy is the fixed tag set. +- The episodic **LLM-judge** distillation layer and **outlier-suggestion** + feature (incl. the `kb_get_suggestions` / `kb_dismiss_suggestion` MCP tools + and the `llama-cpp-python` dependency), plus their `dsagt init` prompts and + config keys. Episodic memory keeps the mechanical capture path so a Tier-0 + baseline can be measured first; design notes parked in + `design-notes/judge.md`. +- Dead `provenance.index_execution_record`. ## [0.2.0] - 2026-06-24 From 48553f13bd3dbb34427c46bd87b2b1ac91c3bb43 Mon Sep 17 00:00:00 2001 From: aarontuor Date: Mon, 6 Jul 2026 09:52:55 -0700 Subject: [PATCH 44/44] docs(kb): replace phantom knowledge/ folder with a user-chosen placeholder The "Try it" ingest prompt referenced a knowledge/ folder nothing creates; point it at with a lead-in saying what to substitute. Co-Authored-By: Claude Fable 5 --- docs/knowledge-base.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/knowledge-base.md b/docs/knowledge-base.md index 99ad6bd..f6ef094 100644 --- a/docs/knowledge-base.md +++ b/docs/knowledge-base.md @@ -36,9 +36,10 @@ dsagt init # name it `demo`, and enable the AIDRIN collection in the dsagt start demo ``` -Then, in the agent: +Then, in the agent — substituting `` with any folder of your +own documents (papers, protocols, schemas): -1. > Ingest the docs in `knowledge/` into a collection named `domain`. +1. > Ingest the docs in `` into a collection named `domain`. 2. > Search the `domain` and `aidrin` collections for how to assess data completeness. ## In practice