mirror of
https://github.com/bleak-ai/gcontext.git
synced 2026-08-11 13:19:23 +02:00
Add restart staleness warnings and resource listing improvements
write_file notes when agent.md or command files change; lazy stderr warning (once per class per lifetime); /status reports stale fields; resource listing adds parent entries for modules/ and connections/. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
07c8b76906
commit
d7226fce03
@@ -121,6 +121,33 @@ def _index_warning(root: Path, target: Path, content: str, existed: bool) -> str
|
||||
return ""
|
||||
|
||||
|
||||
def _restart_note(root: Path, target: Path) -> str:
|
||||
"""Note text for files that only load at server start, or '' otherwise.
|
||||
|
||||
agent.md is pushed in the MCP handshake and command files register as
|
||||
prompts at startup; a write through this tool takes effect only after a
|
||||
restart. Advisory only, same contract as _index_warning.
|
||||
"""
|
||||
parts = target.relative_to(root.resolve()).parts
|
||||
if parts == ("agent.md",):
|
||||
return (
|
||||
" Note: agent.md is pushed at connect; this change reaches clients "
|
||||
"only after a restart (stop the server, gcontext up, reconnect the client)."
|
||||
)
|
||||
if (
|
||||
len(parts) == 4
|
||||
and parts[0] in ("connections", "modules")
|
||||
and parts[2] == "commands"
|
||||
and target.suffix in (".md", ".py")
|
||||
):
|
||||
return (
|
||||
" Note: commands are registered at server start; this command appears "
|
||||
"(or updates) only after a restart (stop the server, gcontext up, "
|
||||
"reconnect the client)."
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
DIFF_MAX_LINES = 200
|
||||
|
||||
|
||||
@@ -163,6 +190,7 @@ def write_file(root: Path, path: str, content: str) -> str:
|
||||
return (
|
||||
line
|
||||
+ _index_warning(root, target, content, existed)
|
||||
+ _restart_note(root, target)
|
||||
+ (_write_diff(path, before, content) if existed else "")
|
||||
)
|
||||
|
||||
|
||||
+77
-6
@@ -51,6 +51,61 @@ def _tool_doc(name: str) -> str:
|
||||
# Live MCP sessions, keyed by session id: {"client": ..., "connected": ..., "last_seen": ...}
|
||||
SESSIONS: dict[str, dict] = {}
|
||||
|
||||
# Two file classes load only at server start: agent.md (pushed in the MCP
|
||||
# handshake) and command files (registered as prompts). No watchers, per the
|
||||
# no-background-behavior design: a startup snapshot of their mtimes, compared
|
||||
# lazily on tool calls, with one stderr line per class per server lifetime.
|
||||
STARTUP_SNAPSHOT: dict = {"agent_md": None, "commands": {}}
|
||||
_STALE = {"agent_md": False, "commands": False}
|
||||
_STALE_WARNED = {"agent_md": False, "commands": False}
|
||||
_STALE_CHECK_INTERVAL = 5.0
|
||||
_last_stale_check = 0.0
|
||||
|
||||
|
||||
def _mtime(path: Path) -> float | None:
|
||||
try:
|
||||
return path.stat().st_mtime
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def snapshot_startup_files():
|
||||
"""Record the state of the start-time-loaded files. Call once, after
|
||||
load_instructions() and register_commands() have run."""
|
||||
STARTUP_SNAPSHOT["agent_md"] = _mtime(PROJECT_DIR / "agent.md")
|
||||
STARTUP_SNAPSHOT["commands"] = {
|
||||
str(p): _mtime(p) for p in commands_mod.discover(PROJECT_DIR)
|
||||
}
|
||||
_STALE.update(agent_md=False, commands=False)
|
||||
_STALE_WARNED.update(agent_md=False, commands=False)
|
||||
|
||||
|
||||
def check_staleness(force: bool = False) -> dict:
|
||||
"""Compare the current files against the startup snapshot.
|
||||
|
||||
Once a class is stale it stays stale until restart, so the comparison for
|
||||
it stops. Throttled to one filesystem check per few seconds unless forced.
|
||||
"""
|
||||
global _last_stale_check
|
||||
now = time.monotonic()
|
||||
if not force and now - _last_stale_check < _STALE_CHECK_INTERVAL:
|
||||
return dict(_STALE)
|
||||
_last_stale_check = now
|
||||
if not _STALE["agent_md"]:
|
||||
_STALE["agent_md"] = _mtime(PROJECT_DIR / "agent.md") != STARTUP_SNAPSHOT["agent_md"]
|
||||
if not _STALE["commands"]:
|
||||
current = {str(p): _mtime(p) for p in commands_mod.discover(PROJECT_DIR)}
|
||||
_STALE["commands"] = current != STARTUP_SNAPSHOT["commands"]
|
||||
if _STALE["agent_md"] and not _STALE_WARNED["agent_md"]:
|
||||
_STALE_WARNED["agent_md"] = True
|
||||
print(" ! agent.md changed since start; restart to push the new version "
|
||||
"(stop, gcontext up, reconnect the client)", file=sys.stderr)
|
||||
if _STALE["commands"] and not _STALE_WARNED["commands"]:
|
||||
_STALE_WARNED["commands"] = True
|
||||
print(" ! commands changed since start; restart to re-register them",
|
||||
file=sys.stderr)
|
||||
return dict(_STALE)
|
||||
|
||||
# Activity feed for the dashboard: in-memory ring buffer, gone on restart.
|
||||
EVENTS: deque = deque(maxlen=300)
|
||||
_EVENT_SEQ = itertools.count(1)
|
||||
@@ -125,6 +180,7 @@ class ConnectionTracker(Middleware):
|
||||
return await call_next(context)
|
||||
|
||||
async def on_call_tool(self, context, call_next):
|
||||
check_staleness()
|
||||
name = getattr(context.message, "name", "?")
|
||||
arguments = getattr(context.message, "arguments", None) or {}
|
||||
detail = _event_detail(name, arguments)
|
||||
@@ -166,18 +222,32 @@ class ConnectionTracker(Middleware):
|
||||
name=agent_name,
|
||||
mime_type="text/markdown",
|
||||
))
|
||||
for name in state.discover_modules(PROJECT_DIR):
|
||||
modules = state.discover_modules(PROJECT_DIR)
|
||||
if modules:
|
||||
result.append(Resource(
|
||||
uri=f"agent://{agent_name}/modules/{name}",
|
||||
name=f"modules / {name}",
|
||||
uri=f"agent://{agent_name}/modules",
|
||||
name="modules",
|
||||
mime_type="text/markdown",
|
||||
))
|
||||
for name in state.load_connections(PROJECT_DIR):
|
||||
for name in modules:
|
||||
result.append(Resource(
|
||||
uri=f"agent://{agent_name}/modules/{name}",
|
||||
name=f"modules / {name}",
|
||||
mime_type="text/markdown",
|
||||
))
|
||||
connections = state.load_connections(PROJECT_DIR)
|
||||
if connections:
|
||||
result.append(Resource(
|
||||
uri=f"agent://{agent_name}/connections/{name}",
|
||||
name=f"connections / {name}",
|
||||
uri=f"agent://{agent_name}/connections",
|
||||
name="connections",
|
||||
mime_type="text/markdown",
|
||||
))
|
||||
for name in connections:
|
||||
result.append(Resource(
|
||||
uri=f"agent://{agent_name}/connections/{name}",
|
||||
name=f"connections / {name}",
|
||||
mime_type="text/markdown",
|
||||
))
|
||||
return result
|
||||
|
||||
async def on_read_resource(self, context, call_next):
|
||||
@@ -210,6 +280,7 @@ async def status_route(request: Request) -> JSONResponse:
|
||||
"name": config.get("name", PROJECT_DIR.name),
|
||||
"project_dir": str(PROJECT_DIR.resolve()),
|
||||
"sessions": list(SESSIONS.values()),
|
||||
"stale": check_staleness(force=True),
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -83,6 +83,26 @@ def test_api_tree_excludes_machine_and_secret_files(client):
|
||||
assert not any(p.startswith(".venv") for p in paths)
|
||||
|
||||
|
||||
def test_status_reports_staleness(client, project):
|
||||
import os
|
||||
|
||||
server.snapshot_startup_files()
|
||||
stale = client.get("/status").json()["stale"]
|
||||
assert stale == {"agent_md": False, "commands": False}
|
||||
|
||||
agent_md = project / "agent.md"
|
||||
os.utime(agent_md, (agent_md.stat().st_mtime + 10,) * 2)
|
||||
stale = client.get("/status").json()["stale"]
|
||||
assert stale["agent_md"] is True
|
||||
assert stale["commands"] is False
|
||||
|
||||
cmd = project / "modules" / "notes" / "commands" / "report.md"
|
||||
cmd.parent.mkdir(parents=True)
|
||||
cmd.write_text("---\ndescription: d\n---\nbody\n")
|
||||
stale = client.get("/status").json()["stale"]
|
||||
assert stale["commands"] is True
|
||||
|
||||
|
||||
def test_api_events_limit_since_and_ring_cap(client):
|
||||
for i in range(350):
|
||||
server.record_event("s", "tool", f"tool{i}")
|
||||
|
||||
@@ -98,6 +98,32 @@ def test_new_file_without_parent_index_does_not_warn(project):
|
||||
assert "Warning" not in out
|
||||
|
||||
|
||||
def test_agent_md_write_notes_restart(project):
|
||||
out = server.write_file("agent.md", "# Agent\nbe useful\n")
|
||||
assert "Note: agent.md is pushed at connect" in out
|
||||
assert "restart" in out
|
||||
assert "Warning" not in out # agent.md stays exempt from the index check
|
||||
|
||||
|
||||
def test_command_write_notes_restart(project):
|
||||
out = server.write_file(
|
||||
"connections/gmail/commands/send.md", "---\ndescription: d\n---\nbody\n"
|
||||
)
|
||||
assert "Note: commands are registered at server start" in out
|
||||
out = server.write_file(
|
||||
"modules/notes/commands/report.py", "# ---\n# description: d\n# ---\n"
|
||||
)
|
||||
assert "Note: commands are registered at server start" in out
|
||||
# Not a command file: wrong folder or wrong extension.
|
||||
assert "Note:" not in server.write_file("modules/notes/scripts/report.py", "x")
|
||||
assert "Note:" not in server.write_file("modules/notes/commands/notes.txt", "x")
|
||||
|
||||
|
||||
def test_ordinary_write_has_no_restart_note(project):
|
||||
out = server.write_file("modules/notes/index.md", "summary")
|
||||
assert "Note:" not in out
|
||||
|
||||
|
||||
def test_write_new_file_reports_size_and_lines(project):
|
||||
out = server.write_file("modules/notes/note.md", "one\ntwo\n")
|
||||
assert out.startswith("Created: modules/notes/note.md")
|
||||
|
||||
Reference in New Issue
Block a user