Skip to content
Open
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
46 changes: 46 additions & 0 deletions docs/coding-agent-notes.md
Original file line number Diff line number Diff line change
@@ -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
<session_dir>/coding_agent_notes.jsonl
```

If no session directory can be resolved, the fallback path is:

```text
<agent.storage_path>/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"}
}
```
1 change: 1 addition & 0 deletions gently/app/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
97 changes: 97 additions & 0 deletions gently/app/tools/coding_notes_tools.py
Original file line number Diff line number Diff line change
@@ -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)
121 changes: 121 additions & 0 deletions gently/harness/coding_notes.py
Original file line number Diff line number Diff line change
@@ -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
80 changes: 80 additions & 0 deletions tests/test_coding_notes.py
Original file line number Diff line number Diff line change
@@ -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