From 000d48b7fbf40fa5586683b4058c495a034d7455 Mon Sep 17 00:00:00 2001 From: ceej640 <42260127+Ceej640@users.noreply.github.com> Date: Sat, 30 May 2026 22:25:34 -0400 Subject: [PATCH 1/2] Add copilot coding-agent notes --- docs/coding-agent-notes.md | 46 ++++++++++ gently/app/tools/__init__.py | 1 + gently/app/tools/coding_notes_tools.py | 97 ++++++++++++++++++++ gently/harness/coding_notes.py | 121 +++++++++++++++++++++++++ tests/test_coding_notes.py | 79 ++++++++++++++++ 5 files changed, 344 insertions(+) create mode 100644 docs/coding-agent-notes.md create mode 100644 gently/app/tools/coding_notes_tools.py create mode 100644 gently/harness/coding_notes.py create mode 100644 tests/test_coding_notes.py diff --git a/docs/coding-agent-notes.md b/docs/coding-agent-notes.md new file mode 100644 index 00000000..fad0016b --- /dev/null +++ b/docs/coding-agent-notes.md @@ -0,0 +1,46 @@ +# Coding-Agent Notes + +The copilot 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 copilot 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": "copilot", + "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..4960a7b2 --- /dev/null +++ b/gently/harness/coding_notes.py @@ -0,0 +1,121 @@ +"""Persistent notes from the copilot 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 = "copilot" + 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", "copilot")), + 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 = "copilot", + 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..533e94df --- /dev/null +++ b/tests/test_coding_notes.py @@ -0,0 +1,79 @@ +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 copilot 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["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 From 11213998df0b194df4cbe585a4a0f0a6254b3251 Mon Sep 17 00:00:00 2001 From: Johnson Date: Mon, 1 Jun 2026 00:37:46 -0400 Subject: [PATCH 2/2] Use Gently agent naming for coding notes --- docs/coding-agent-notes.md | 6 +++--- gently/harness/coding_notes.py | 8 ++++---- tests/test_coding_notes.py | 3 ++- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/coding-agent-notes.md b/docs/coding-agent-notes.md index fad0016b..1b31fc36 100644 --- a/docs/coding-agent-notes.md +++ b/docs/coding-agent-notes.md @@ -1,6 +1,6 @@ # Coding-Agent Notes -The copilot can leave durable notes for a future coding-agent pass with the +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. @@ -28,7 +28,7 @@ Each record includes: - optional structured `context` The companion `list_coding_agent_notes` tool lists recent notes so the operator -or copilot can confirm what has been captured. +or Gently agent can confirm what has been captured. Example record: @@ -40,7 +40,7 @@ Example record: "session_id": "abc12345", "category": "bug", "severity": "warning", - "source": "copilot", + "source": "gently_agent", "context": {"summary": "move_to_embryo skipped position lookup"} } ``` diff --git a/gently/harness/coding_notes.py b/gently/harness/coding_notes.py index 4960a7b2..64ae969a 100644 --- a/gently/harness/coding_notes.py +++ b/gently/harness/coding_notes.py @@ -1,4 +1,4 @@ -"""Persistent notes from the copilot to a coding agent.""" +"""Persistent notes from the Gently agent to a coding agent.""" from __future__ import annotations @@ -20,7 +20,7 @@ class CodingAgentNote: session_id: Optional[str] = None category: str = "feedback" severity: str = "info" - source: str = "copilot" + source: str = "gently_agent" context: Mapping[str, Any] = field(default_factory=dict) def to_dict(self) -> Dict[str, Any]: @@ -35,7 +35,7 @@ def from_dict(cls, data: Mapping[str, Any]) -> "CodingAgentNote": session_id=data.get("session_id"), category=str(data.get("category", "feedback")), severity=str(data.get("severity", "info")), - source=str(data.get("source", "copilot")), + source=str(data.get("source", "gently_agent")), context=data.get("context") or {}, ) @@ -66,7 +66,7 @@ def append_note( session_id: Optional[str] = None, category: str = "feedback", severity: str = "info", - source: str = "copilot", + source: str = "gently_agent", context: Optional[Mapping[str, Any]] = None, ) -> CodingAgentNote: """Append a note and return the stored record.""" diff --git a/tests/test_coding_notes.py b/tests/test_coding_notes.py index 533e94df..28d92196 100644 --- a/tests/test_coding_notes.py +++ b/tests/test_coding_notes.py @@ -46,7 +46,7 @@ async def test_leave_coding_agent_note_tool_writes_to_session_dir(tmp_path): ) result = await leave_coding_agent_note( - message="The copilot should surface detector errors to the user.", + message="The Gently agent should surface detector errors to the user.", category="bug", severity="warning", context_summary="Detector returned an exception during detection.", @@ -60,6 +60,7 @@ async def test_leave_coding_agent_note_tool_writes_to_session_dir(tmp_path): 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")