diff --git a/docs/coding-agent-notes.md b/docs/coding-agent-notes.md new file mode 100644 index 00000000..1b31fc36 --- /dev/null +++ b/docs/coding-agent-notes.md @@ -0,0 +1,46 @@ +# Coding-Agent Notes + +The Gently runtime agent can leave durable notes for a future coding-agent pass with the +`leave_coding_agent_note` tool. Notes are append-only JSONL records intended for +bug reports, internal errors, implementation feedback, or user requests that +should be addressed in code. + +When a FileStore session is active, notes are written to: + +```text +/coding_agent_notes.jsonl +``` + +If no session directory can be resolved, the fallback path is: + +```text +/coding_agent_notes.jsonl +``` + +Each record includes: + +- `note_id` +- `timestamp` +- `session_id` +- `category` +- `severity` +- `message` +- optional structured `context` + +The companion `list_coding_agent_notes` tool lists recent notes so the operator +or Gently agent can confirm what has been captured. + +Example record: + +```json +{ + "note_id": "8b4f0bc9e0a1", + "timestamp": "2026-05-30T12:00:00", + "message": "User expected movement to verify embryo position first.", + "session_id": "abc12345", + "category": "bug", + "severity": "warning", + "source": "gently_agent", + "context": {"summary": "move_to_embryo skipped position lookup"} +} +``` diff --git a/gently/app/tools/__init__.py b/gently/app/tools/__init__.py index 99c91e34..e1059968 100644 --- a/gently/app/tools/__init__.py +++ b/gently/app/tools/__init__.py @@ -22,6 +22,7 @@ from . import detection_tools from . import plan_execution_tools from . import memory_tools +from . import coding_notes_tools from . import resolution_tools from gently.harness.plan_mode.tools import lab_context as _lab_context # query_lab_history in run mode diff --git a/gently/app/tools/coding_notes_tools.py b/gently/app/tools/coding_notes_tools.py new file mode 100644 index 00000000..43ab2e60 --- /dev/null +++ b/gently/app/tools/coding_notes_tools.py @@ -0,0 +1,97 @@ +"""Tools for leaving notes to future coding-agent work.""" + +from typing import Dict, Optional + +from gently.harness.coding_notes import CodingNotesStore +from gently.harness.tools.registry import tool, ToolCategory, ToolExample + + +def _get_notes_store(context: Dict): + agent = context.get("agent") if context else None + if not agent: + return None, None, "Error: No agent context" + return CodingNotesStore.for_agent(agent), agent, None + + +@tool( + name="leave_coding_agent_note", + description=( + "Write a persistent note for a future coding agent. Use this when the " + "user reports a bug, asks for code changes, gives implementation " + "feedback, or when an internal error reveals something maintainers " + "should fix later." + ), + category=ToolCategory.UTILITY, + examples=[ + ToolExample( + "Leave a bug note", + { + "message": "User expected move_to_embryo to verify a stored position first.", + "category": "bug", + "severity": "warning", + }, + ), + ], +) +async def leave_coding_agent_note( + message: str, + category: str = "feedback", + severity: str = "info", + context_summary: Optional[str] = None, + context: Dict = None, +) -> str: + """Persist a coding-agent note.""" + store, agent, err = _get_notes_store(context) + if err: + return err + + note_context = {} + if context_summary: + note_context["summary"] = context_summary + + try: + note = store.append_note( + message, + session_id=getattr(agent, "session_id", None), + category=category, + severity=severity, + context=note_context, + ) + except ValueError as exc: + return f"Error writing coding-agent note: {exc}" + + return f"Saved coding-agent note {note.note_id} to {store.path}" + + +@tool( + name="list_coding_agent_notes", + description=( + "List recent persistent notes that were left for a future coding agent. " + "Use this to review outstanding bug reports or implementation feedback." + ), + category=ToolCategory.UTILITY, + examples=[ + ToolExample("Show coding notes", {"limit": 5}), + ], +) +async def list_coding_agent_notes( + limit: int = 10, + category: Optional[str] = None, + context: Dict = None, +) -> str: + """List recent coding-agent notes.""" + store, _agent, err = _get_notes_store(context) + if err: + return err + + notes = store.list_notes(limit=limit, category=category) + if not notes: + return f"No coding-agent notes found at {store.path}" + + lines = [f"Coding-agent notes ({store.path}):"] + for note in notes: + lines.append( + f"- [{note.severity}/{note.category}] {note.timestamp} " + f"{note.note_id}: {note.message}" + ) + return "\n".join(lines) diff --git a/gently/harness/coding_notes.py b/gently/harness/coding_notes.py new file mode 100644 index 00000000..64ae969a --- /dev/null +++ b/gently/harness/coding_notes.py @@ -0,0 +1,121 @@ +"""Persistent notes from the Gently agent to a coding agent.""" + +from __future__ import annotations + +import json +import uuid +from dataclasses import asdict, dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Mapping, Optional + + +@dataclass(frozen=True) +class CodingAgentNote: + """A structured note intended for a future coding-agent pass.""" + + note_id: str + timestamp: str + message: str + session_id: Optional[str] = None + category: str = "feedback" + severity: str = "info" + source: str = "gently_agent" + context: Mapping[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + return asdict(self) + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "CodingAgentNote": + return cls( + note_id=str(data.get("note_id", "")), + timestamp=str(data.get("timestamp", "")), + message=str(data.get("message", "")), + session_id=data.get("session_id"), + category=str(data.get("category", "feedback")), + severity=str(data.get("severity", "info")), + source=str(data.get("source", "gently_agent")), + context=data.get("context") or {}, + ) + + +class CodingNotesStore: + """Append-only JSONL store for coding-agent notes.""" + + def __init__(self, path: Path): + self.path = Path(path) + + @classmethod + def for_agent(cls, agent) -> "CodingNotesStore": + """Resolve the best notes path for an agent-like object.""" + session_id = getattr(agent, "session_id", None) + store = getattr(agent, "store", None) + if store is not None and session_id and hasattr(store, "_session_dir"): + session_dir = store._session_dir(session_id) + if session_dir is not None: + return cls(session_dir / "coding_agent_notes.jsonl") + + storage_path = Path(getattr(agent, "storage_path", ".")) + return cls(storage_path / "coding_agent_notes.jsonl") + + def append_note( + self, + message: str, + *, + session_id: Optional[str] = None, + category: str = "feedback", + severity: str = "info", + source: str = "gently_agent", + context: Optional[Mapping[str, Any]] = None, + ) -> CodingAgentNote: + """Append a note and return the stored record.""" + cleaned = message.strip() + if not cleaned: + raise ValueError("note message cannot be empty") + + note = CodingAgentNote( + note_id=uuid.uuid4().hex[:12], + timestamp=datetime.now().isoformat(), + message=cleaned, + session_id=session_id, + category=category.strip().lower() or "feedback", + severity=severity.strip().lower() or "info", + source=source, + context=dict(context or {}), + ) + + self.path.parent.mkdir(parents=True, exist_ok=True) + with self.path.open("a", encoding="utf-8") as f: + f.write(json.dumps(note.to_dict(), default=str) + "\n") + return note + + def list_notes( + self, + *, + limit: Optional[int] = None, + category: Optional[str] = None, + ) -> List[CodingAgentNote]: + """Read notes from newest to oldest.""" + if not self.path.exists(): + return [] + + wanted_category = category.strip().lower() if category else None + notes: List[CodingAgentNote] = [] + with self.path.open("r", encoding="utf-8") as f: + for raw in f: + raw = raw.strip() + if not raw: + continue + try: + note = CodingAgentNote.from_dict(json.loads(raw)) + except json.JSONDecodeError: + continue + if wanted_category and note.category != wanted_category: + continue + notes.append(note) + + notes.reverse() + if limit is not None: + return notes[:max(0, limit)] + return notes diff --git a/tests/test_coding_notes.py b/tests/test_coding_notes.py new file mode 100644 index 00000000..28d92196 --- /dev/null +++ b/tests/test_coding_notes.py @@ -0,0 +1,80 @@ +import json +from types import SimpleNamespace + +import pytest + +from gently.app.tools.coding_notes_tools import ( + leave_coding_agent_note, + list_coding_agent_notes, +) +from gently.core.file_store import FileStore +from gently.harness.coding_notes import CodingNotesStore + + +def test_coding_notes_store_appends_and_lists_newest_first(tmp_path): + store = CodingNotesStore(tmp_path / "notes.jsonl") + + first = store.append_note("First note", session_id="s1", category="bug") + second = store.append_note("Second note", session_id="s1", category="feedback") + + notes = store.list_notes() + raw = [ + json.loads(line) + for line in (tmp_path / "notes.jsonl").read_text(encoding="utf-8").splitlines() + ] + + assert [note.note_id for note in notes] == [second.note_id, first.note_id] + assert raw[0]["message"] == "First note" + assert store.list_notes(category="bug")[0].message == "First note" + + +def test_coding_notes_store_rejects_empty_messages(tmp_path): + store = CodingNotesStore(tmp_path / "notes.jsonl") + + with pytest.raises(ValueError, match="cannot be empty"): + store.append_note(" ") + + +@pytest.mark.asyncio +async def test_leave_coding_agent_note_tool_writes_to_session_dir(tmp_path): + file_store = FileStore(tmp_path) + file_store.create_session("abc12345", name="notes") + agent = SimpleNamespace( + store=file_store, + session_id="abc12345", + storage_path=tmp_path, + ) + + result = await leave_coding_agent_note( + message="The Gently agent should surface detector errors to the user.", + category="bug", + severity="warning", + context_summary="Detector returned an exception during detection.", + context={"agent": agent}, + ) + + session_dir = file_store._session_dir("abc12345") + notes_path = session_dir / "coding_agent_notes.jsonl" + payload = json.loads(notes_path.read_text(encoding="utf-8").strip()) + + assert "Saved coding-agent note" in result + assert payload["session_id"] == "abc12345" + assert payload["category"] == "bug" + assert payload["source"] == "gently_agent" + assert payload["context"]["summary"].startswith("Detector returned") + + +@pytest.mark.asyncio +async def test_list_coding_agent_notes_tool_formats_recent_notes(tmp_path): + agent = SimpleNamespace( + store=None, + session_id=None, + storage_path=tmp_path, + ) + store = CodingNotesStore.for_agent(agent) + store.append_note("Remember to add a regression test.", category="test") + + result = await list_coding_agent_notes(limit=5, context={"agent": agent}) + + assert "Remember to add a regression test." in result + assert str(tmp_path / "coding_agent_notes.jsonl") in result