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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 103 additions & 8 deletions codec_agent_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,20 @@
_CODEC_DIR = Path(os.path.expanduser("~/.codec"))
_AGENTS_DIR = _CODEC_DIR / "agents"
_GLOBAL_GRANTS_PATH = _CODEC_DIR / "agent_global_grants.json"
# Phase 3.5: human-browseable project folder root (Claude Code-style).
# Each agent gets ~/codec-projects/<slugified-title>/ on creation, openable
# in any IDE. Override via ~/.codec/config.json:agents.project_root_dir
# or env CODEC_PROJECT_ROOT_DIR.
_PROJECT_ROOT = Path(os.path.expanduser(
os.environ.get("CODEC_PROJECT_ROOT_DIR", "") or "~/codec-projects"
))

# ── Schema constants ──────────────────────────────────────────────────────────
PLAN_SCHEMA_VERSION = 1
GLOBAL_GRANTS_SCHEMA_VERSION = 1
DEFAULT_STEP_BUDGET_PER_CHECKPOINT = 30
MAX_CLARIFYING_ROUNDS = 3
MAX_PROJECT_SLUG_LEN = 50


# ── Dataclasses ───────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -302,10 +310,15 @@ def _qwen_chat(user_prompt: str, system_prompt: str = "",


def draft_plan(agent_id: str, description: str, registry=None,
available_skills: Optional[List[str]] = None) -> Plan:
available_skills: Optional[List[str]] = None,
project_dir: Optional[Path] = None) -> Plan:
"""Call Qwen-3.6 with the project description, parse response into Plan,
validate against skill registry. Raises PlanValidationError on schema or
validation failure; QwenUnavailableError on LLM unavailability."""
validation failure; QwenUnavailableError on LLM unavailability.

Phase 3.5: when `project_dir` is provided, the LLM is told to default
write_paths to that folder so files the agent creates land somewhere
the user can open in an IDE."""
if registry is None:
try:
from codec_dispatch import registry as _reg
Expand All @@ -316,9 +329,20 @@ def draft_plan(agent_id: str, description: str, registry=None,
if available_skills is None:
available_skills = sorted(registry.names() or [])

project_hint = ""
if project_dir is not None:
project_hint = (
f"\nProject folder: {project_dir}\n"
f"Default write_paths for this agent: \"{project_dir}/**\". "
f"Files the user will open in their IDE land here. "
f"Don't write outside this folder unless the project description "
f"explicitly requires it.\n"
)

user_prompt = (
f"agent_id: {agent_id}\n\n"
f"Available skills (registry): {', '.join(available_skills)}\n\n"
f"Available skills (registry): {', '.join(available_skills)}\n"
f"{project_hint}\n"
f"Project description:\n{description}\n\n"
f"Generate the JSON plan now."
)
Expand Down Expand Up @@ -393,16 +417,21 @@ def _ask_user(question: str, *, agent_id: str,

def draft_plan_with_clarification(agent_id: str, description: str,
registry=None,
max_rounds: int = MAX_CLARIFYING_ROUNDS) -> Plan:
max_rounds: int = MAX_CLARIFYING_ROUNDS,
project_dir: Optional[Path] = None) -> Plan:
"""Wrap draft_plan with a clarifying-question loop. If LLM returns
{"too_vague": True, "clarifying_questions": [...]}, ask user via
codec_ask_user.ask, append answers to description, retry. After
max_rounds without convergence, raise DescriptionTooVagueError."""
max_rounds without convergence, raise DescriptionTooVagueError.

Phase 3.5: `project_dir` (when provided) is forwarded to draft_plan
so the LLM defaults write_paths to the human-browseable folder."""
enriched_description = description

for round_idx in range(max_rounds + 1):
try:
return draft_plan(agent_id, enriched_description, registry=registry)
return draft_plan(agent_id, enriched_description, registry=registry,
project_dir=project_dir)
except PlanValidationError as e:
# Check if this was a "too_vague" response (sentinel from LLM)
if "too_vague" in str(e).lower():
Expand Down Expand Up @@ -554,6 +583,56 @@ def _audit(event: str, source: str, message: str = "",
level=level, extra=dict(extra or {}))


# ── Project folder (Phase 3.5: Claude Code-style human-browseable dir) ────────
_SLUG_RE = re.compile(r"[^a-z0-9]+")


def _slugify(title: str) -> str:
"""Convert a title to a filesystem-safe slug.
"Build a Telegram bot for Marbella property!" → "build-a-telegram-bot-for-marbella-property".
Falls back to "project" if the title has no alphanumeric characters."""
s = _SLUG_RE.sub("-", (title or "").lower()).strip("-")
if not s:
s = "project"
return s[:MAX_PROJECT_SLUG_LEN].rstrip("-") or "project"


def _project_root() -> Path:
"""Return the configured project root, with config.json override.
Resolved at call time so tests can monkeypatch _PROJECT_ROOT or set
CODEC_PROJECT_ROOT_DIR via monkeypatch.setenv."""
# config.json override (~/.codec/config.json:agents.project_root_dir)
cfg = _CODEC_DIR / "config.json"
if cfg.exists():
try:
data = json.loads(cfg.read_text())
override = (data.get("agents") or {}).get("project_root_dir")
if override:
return Path(os.path.expanduser(override))
except Exception:
pass
return _PROJECT_ROOT


def create_project_folder(title: str, agent_id: str) -> Path:
"""Create a human-browseable project folder for this agent.
Returns absolute Path. Disambiguates if the slug already exists."""
root = _project_root()
root.mkdir(parents=True, exist_ok=True)
base_slug = _slugify(title)
candidate = root / base_slug
suffix = 2
while candidate.exists():
candidate = root / f"{base_slug}-{suffix}"
suffix += 1
if suffix > 99:
# Pathological case: 99 collisions → fall back to agent_id suffix
candidate = root / f"{base_slug}-{agent_id}"
break
candidate.mkdir(parents=True, exist_ok=False)
return candidate.resolve()


# ── Public orchestrator ───────────────────────────────────────────────────────
def _new_agent_id() -> str:
return f"agent_{secrets.token_hex(6)}"
Expand All @@ -564,10 +643,24 @@ def create_agent(title: str, description: str,
notification_channels: Optional[List[str]] = None) -> str:
"""Top-level entry point. Drafts a plan, persists to disk, emits audit.
Returns the new agent_id. Status after this call: awaiting_approval
(or plan_failed on validation error)."""
(or plan_failed on validation error).

Phase 3.5: ALSO creates a human-browseable project folder under
~/codec-projects/<slug>/ (or the configured project_root_dir). The
plan's permission_manifest.write_paths defaults to this folder, so
files the agent creates land where the user can open them in an IDE.
"""
agent_id = _new_agent_id()
cid = secrets.token_hex(6)

# Phase 3.5: create the human-browseable project folder up-front so
# the plan-drafter can reference it as the default write_paths root.
try:
project_dir = create_project_folder(title, agent_id)
except Exception as e:
log.warning("[%s] project folder creation failed: %s", agent_id, e)
project_dir = None

# Initial manifest
manifest = {
"agent_id": agent_id,
Expand All @@ -576,13 +669,15 @@ def create_agent(title: str, description: str,
"created_at": _now_iso(),
"updated_at": _now_iso(),
"notification_channels": notification_channels or ["pwa"],
"project_dir": str(project_dir) if project_dir else None,
}
save_manifest(agent_id, manifest)
save_state(agent_id, {"current_checkpoint": 0})

# Draft plan (with clarification loop)
try:
plan = draft_plan_with_clarification(agent_id, description, registry=registry)
plan = draft_plan_with_clarification(agent_id, description, registry=registry,
project_dir=project_dir)
except DescriptionTooVagueError as e:
set_status(agent_id, "plan_failed", reason=f"too_vague: {e}")
_audit(AGENT_PLAN_REJECTED, "codec-agent-plan",
Expand Down
11 changes: 10 additions & 1 deletion codec_chat.html
Original file line number Diff line number Diff line change
Expand Up @@ -798,9 +798,18 @@ <h1><a href="/" style="color:inherit;text-decoration:none">CODEC</a></h1>
pendingDiv.querySelector('.msg-bubble').innerHTML='<span style="color:var(--danger,#f87171)">Plan failed:</span> '+escHtml(pd.detail);
chatHist.push({role:'assistant',content:'Plan failed: '+pd.detail});
}else if(pd.agent_id){
var folderHtml='';
if(pd.project_dir){
folderHtml='<div style="margin:6px 0 8px;padding:8px 10px;background:rgba(167,139,250,0.08);border:1px solid var(--accent,#a78bfa);border-radius:6px;font-size:12px">'+
'<div style="font-weight:600;color:var(--accent,#a78bfa);margin-bottom:2px">Project folder created</div>'+
'<code style="font-size:11px;word-break:break-all">'+escHtml(pd.project_dir)+'</code>'+
'<div style="color:var(--text-dim);margin-top:4px;font-size:11px">Open this folder in your IDE to see the agent\'s files as they\'re created. (cmd+click to open in Finder)</div>'+
'</div>';
}
var html='<div style="font-weight:600;margin-bottom:6px">Project drafted</div>'+
'<div style="margin-bottom:6px">agent_id: <code>'+escHtml(pd.agent_id)+'</code></div>'+
'<div style="color:var(--text-dim);font-size:12px;margin-bottom:8px">A plan with a permission manifest has been written to <code>~/.codec/agents/'+escHtml(pd.agent_id)+'/plan.json</code>. Review and approve to start.</div>'+
folderHtml+
'<div style="color:var(--text-dim);font-size:12px;margin-bottom:8px">A plan with a permission manifest has been written. Review and approve to let the agent run autonomously.</div>'+
'<div style="display:flex;gap:8px;flex-wrap:wrap">'+
'<button onclick="approveAgentInChat(\''+pd.agent_id+'\',event)" style="padding:6px 14px;background:var(--accent,#a78bfa);color:#000;border:none;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600">Approve plan</button>'+
'<button onclick="rejectAgentInChat(\''+pd.agent_id+'\',event)" style="padding:6px 14px;background:transparent;color:var(--text);border:1px solid var(--border,#2a2a30);border-radius:6px;cursor:pointer;font-size:12px">Reject</button>'+
Expand Down
6 changes: 5 additions & 1 deletion routes/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,11 @@ def create_agent(body: CreateAgentBody):
raise HTTPException(status_code=503, detail=f"Qwen-3.6 unavailable: {e}")

manifest = _cap.load_manifest(agent_id)
return {"agent_id": agent_id, "status": manifest.get("status", "unknown")}
return {
"agent_id": agent_id,
"status": manifest.get("status", "unknown"),
"project_dir": manifest.get("project_dir"), # Phase 3.5: human-browseable folder
}


@router.get("/api/agents")
Expand Down
122 changes: 121 additions & 1 deletion tests/test_agent_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ def temp_codec_dir(tmp_path, monkeypatch):
monkeypatch.setattr(cap, "_CODEC_DIR", tmp_path)
monkeypatch.setattr(cap, "_AGENTS_DIR", tmp_path / "agents")
monkeypatch.setattr(cap, "_GLOBAL_GRANTS_PATH", tmp_path / "agent_global_grants.json")
# Phase 3.5: redirect project root so create_project_folder doesn't
# touch the real ~/codec-projects/ during tests.
monkeypatch.setattr(cap, "_PROJECT_ROOT", tmp_path / "codec-projects")
# Test-pollution fix: redirect codec_audit._AUDIT_LOG so audit emits
# during plan creation/approval do NOT leak into production ~/.codec/audit.log.
monkeypatch.setattr(codec_audit, "_AUDIT_LOG", tmp_path / "audit.log")
Expand Down Expand Up @@ -523,7 +526,7 @@ def test_post_api_agents_creates_drafts(monkeypatch, temp_codec_dir, tmp_path):
"estimated_duration_minutes": 5, "assumptions": []}))
fake_reg = MagicMock(); fake_reg.names.return_value = ["weather"]
monkeypatch.setattr("codec_agent_plan.draft_plan_with_clarification",
lambda agent_id, desc, registry=None, max_rounds=3:
lambda agent_id, desc, registry=None, max_rounds=3, project_dir=None:
cap.draft_plan(agent_id, desc, registry=fake_reg))

from routes.agents import router
Expand Down Expand Up @@ -790,3 +793,120 @@ def fake_audit(event, source, message="", correlation_id="", **kw):
events = [e for e, _ in audit_emits]
assert "agent_plan_drafted" in events
assert "agent_plan_approved" in events


# ─────────────────────────────────────────────────────────────────────────────
# Phase 3.5 — Project folder creation (Claude Code-style human-browseable dir)
# ─────────────────────────────────────────────────────────────────────────────

def test_slugify_basic():
from codec_agent_plan import _slugify
assert _slugify("Build a Telegram bot") == "build-a-telegram-bot"
assert _slugify("Marbella Property Bot!!!") == "marbella-property-bot"
assert _slugify("EUR/USD vol monitor") == "eur-usd-vol-monitor"


def test_slugify_handles_unicode_and_empty():
from codec_agent_plan import _slugify
# Unicode and special chars stripped, ascii-only slug
assert _slugify("Café résumé") == "caf-r-sum"
# Pure non-alphanumeric falls back to "project"
assert _slugify("!!!") == "project"
assert _slugify("") == "project"


def test_slugify_truncates_long_titles():
from codec_agent_plan import _slugify, MAX_PROJECT_SLUG_LEN
long_title = "build " + "a-very-long-project-title-" * 10
slug = _slugify(long_title)
assert len(slug) <= MAX_PROJECT_SLUG_LEN
assert not slug.endswith("-") # trailing dash trimmed


def test_create_project_folder_creates_dir(temp_codec_dir):
from codec_agent_plan import create_project_folder
folder = create_project_folder("Marbella property bot", "agent_abc123")
assert folder.exists() and folder.is_dir()
assert folder.name == "marbella-property-bot"
assert folder.parent == (temp_codec_dir / "codec-projects").resolve()


def test_create_project_folder_disambiguates_collisions(temp_codec_dir):
from codec_agent_plan import create_project_folder
a = create_project_folder("Property bot", "agent_a")
b = create_project_folder("Property bot", "agent_b")
c = create_project_folder("Property bot", "agent_c")
assert a.name == "property-bot"
assert b.name == "property-bot-2"
assert c.name == "property-bot-3"
assert a.exists() and b.exists() and c.exists()


def test_create_agent_writes_project_dir_in_manifest(monkeypatch, temp_codec_dir):
"""create_agent populates manifest.project_dir with the resolved path."""
import codec_agent_plan as cap
monkeypatch.setattr(cap, "_qwen_chat", lambda *a, **k: json.dumps({
"goals": ["g"],
"checkpoints": [{"title": "t", "description": "d",
"skills_needed": ["weather"],
"expected_output": "o", "step_budget": 10}],
"permission_manifest": {"read_paths": [], "write_paths": [],
"network_domains": [], "skills": ["weather"],
"destructive_ops": []},
"estimated_duration_minutes": 5, "assumptions": [],
}))
fake_reg = MagicMock()
fake_reg.names.return_value = ["weather"]
monkeypatch.setattr("codec_dispatch.registry", fake_reg, raising=False)

agent_id = cap.create_agent(title="Property bot for Marbella",
description="Build a property bot")

m = cap.load_manifest(agent_id)
assert m.get("project_dir"), "manifest must include project_dir after create_agent"
pdir = Path(m["project_dir"])
assert pdir.exists() and pdir.is_dir()
assert pdir.name == "property-bot-for-marbella"
# The project folder lives under the configured root (tmp during tests)
assert (temp_codec_dir / "codec-projects").resolve() in pdir.parents


def test_draft_plan_includes_project_dir_in_qwen_prompt(monkeypatch, temp_codec_dir):
"""When project_dir is passed to draft_plan, the user_prompt sent to Qwen
must include the path so the LLM defaults write_paths to it."""
import codec_agent_plan as cap
captured = {"prompt": None}
def capture(user_prompt, system_prompt, max_tokens=4000):
captured["prompt"] = user_prompt
return json.dumps({
"goals": ["g"], "checkpoints": [{"title": "t", "description": "d",
"skills_needed": ["weather"], "expected_output": "o", "step_budget": 10}],
"permission_manifest": {"read_paths": [], "write_paths": [],
"network_domains": [], "skills": ["weather"], "destructive_ops": []},
"estimated_duration_minutes": 5, "assumptions": []})
monkeypatch.setattr(cap, "_qwen_chat", capture)
fake_reg = MagicMock()
fake_reg.names.return_value = ["weather"]

pdir = temp_codec_dir / "codec-projects" / "test-project"
pdir.mkdir(parents=True)
cap.draft_plan(agent_id="agent_x", description="build x",
registry=fake_reg, project_dir=pdir)

assert captured["prompt"] is not None
assert str(pdir) in captured["prompt"]
assert "Project folder" in captured["prompt"]
assert "write_paths" in captured["prompt"]


def test_project_root_config_override(temp_codec_dir, monkeypatch):
"""~/.codec/config.json:agents.project_root_dir overrides the env default."""
import codec_agent_plan as cap
custom = temp_codec_dir / "my-custom-projects"
cfg_path = temp_codec_dir / "config.json"
cfg_path.write_text(json.dumps({
"agents": {"project_root_dir": str(custom)}
}))
# _project_root() reads ~/.codec/config.json; _CODEC_DIR is patched to tmp_path
folder = cap.create_project_folder("Custom test", "agent_x")
assert custom in folder.parents or folder.parent == custom.resolve()
Loading