diff --git a/docs/workflows.md b/docs/workflows.md index 8406d51..c93b569 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -1,6 +1,6 @@ # Workflows -A context-based workflow is a module with a fixed shape. It is a series of steps the agent executes with judgment, where every run leaves a persistent trace on disk. The workflow remembers what happened last run, accumulates knowledge, and gets better over time. A skill or prompt runs and forgets; a workflow holds state. +A context-based workflow is a module with a fixed shape. Every workflow lives in `modules/` as a module, but not every module is a workflow: a module is any folder of files that holds accumulated knowledge on a topic; a workflow is a module that follows the specific structure defined here (frontmatter manifest, steps/, runs/). It is a series of steps the agent executes with judgment, where every run leaves a persistent trace on disk. The workflow remembers what happened last run, accumulates knowledge, and gets better over time. A skill or prompt runs and forgets; a workflow holds state. This document is the template spec: the contract a workflow folder must follow to be distributable. The CLI (`gcontext add`), the site directory, and the authoring tooling all build against it. It is one standard for all workflows; there are no per-domain variants. diff --git a/gcontext/server.py b/gcontext/server.py index 1e2e408..b8cac6c 100644 --- a/gcontext/server.py +++ b/gcontext/server.py @@ -154,18 +154,41 @@ class ConnectionTracker(Middleware): return await call_next(context) async def on_list_resources(self, context, call_next): - """The resource list is the state folder, scanned live: every listable - file at gcontext://, so runtimes can offer them for attachment.""" - result = await call_next(context) - for rel in fs.walk_files(PROJECT_DIR): - mime = "text/markdown" if rel.endswith(".md") else "text/plain" - result.append(Resource(uri=f"gcontext://{rel}", name=rel, mime_type=mime)) + """Curated resource list: the agent entry point plus each module and + connection. Every file stays readable via the gcontext://{path*} + template; only the entry points appear as suggestions.""" + await call_next(context) + result = [] + config = state.load_gcontext_yaml(PROJECT_DIR) + agent_name = config.get("name", PROJECT_DIR.name) + result.append(Resource( + uri=f"agent://{agent_name}", + name=agent_name, + mime_type="text/markdown", + )) + for name in state.discover_modules(PROJECT_DIR): + result.append(Resource( + uri=f"agent://{agent_name}/modules/{name}", + name=f"modules / {name}", + mime_type="text/markdown", + )) + for name in state.load_connections(PROJECT_DIR): + 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): + from fastmcp.resources.base import ResourceResult uri = str(getattr(context.message, "uri", "?")) start = time.perf_counter() - result = await call_next(context) + text = _resolve_resource_uri(uri) + if text is not None: + result = ResourceResult(text) + else: + result = await call_next(context) record_event(_session_id(context), "resource", "resource", detail=uri, duration_ms=round((time.perf_counter() - start) * 1000)) return result @@ -221,16 +244,58 @@ def load_instructions() -> tuple[int, int]: return len(base.splitlines()), len(text.splitlines()) -@mcp.resource("gcontext://{path*}", - description=(_PROMPTS_DIR / "resources.md").read_text().strip()) -def state_resource(path: str) -> str: - rel = path.rstrip("/") - target, error = fs.resolve_path(PROJECT_DIR, rel) - if error: - return f"Error: {error}." - if target.is_dir(): - return fs.list_dir(PROJECT_DIR, rel or ".") - return fs.read_file(PROJECT_DIR, rel) +def _resolve_resource_uri(uri: str) -> str | None: + """Resolve a resource URI to text content, or None if unrecognised.""" + if uri.startswith("agent://"): + path = uri[len("agent://"):].rstrip("/") + parts = path.split("/", 1) + rel = parts[1] if len(parts) > 1 else "" + if not rel: + return _ask_resource() + target, error = fs.resolve_path(PROJECT_DIR, rel) + if error: + return f"Error: {error}." + if target.is_dir(): + index = target / "index.md" + if index.is_file(): + return fs.read_file(PROJECT_DIR, f"{rel}/index.md") + return fs.list_dir(PROJECT_DIR, rel) + return fs.read_file(PROJECT_DIR, rel) + if uri.startswith("gcontext://"): + rel = uri[len("gcontext://"):].rstrip("/") + target, error = fs.resolve_path(PROJECT_DIR, rel) + if error: + return f"Error: {error}." + if target.is_dir(): + return fs.list_dir(PROJECT_DIR, rel or ".") + return fs.read_file(PROJECT_DIR, rel) + return None + + +def _ask_resource() -> str: + """Build the 'ask' resource: agent.md plus a map of modules and connections.""" + config = state.load_gcontext_yaml(PROJECT_DIR) + agent_name = config.get("name", PROJECT_DIR.name) + parts = [f"# {agent_name}\n"] + agent_md = PROJECT_DIR / "agent.md" + if agent_md.exists(): + parts.append(agent_md.read_text().strip()) + parts.append("") + modules = state.discover_modules(PROJECT_DIR) + if modules: + parts.append("## Modules") + for name, manifest in modules.items(): + desc = f" - {manifest.description}" if manifest.description else "" + parts.append(f"- {name}{desc}") + parts.append("") + connections = state.load_connections(PROJECT_DIR) + if connections: + parts.append("## Connections") + for name, manifest in connections.items(): + desc = f" - {manifest.description}" if manifest.description else "" + parts.append(f"- {name}{desc}") + parts.append("") + return "\n".join(parts) # output_schema=None on every tool: with a schema, FastMCP wraps the string diff --git a/tests/test_commands.py b/tests/test_commands.py index 0a8c721..ee09932 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -113,7 +113,7 @@ def test_commands_ledger_pipe(project): def test_register_framework_prompts_setup(): mcp = FastMCP("t") - assert commands.register_framework_prompts(mcp) == 1 + assert commands.register_framework_prompts(mcp) == 2 async def go(): async with Client(mcp) as c: diff --git a/tests/test_server.py b/tests/test_server.py index 4badd82..4c21e35 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -338,7 +338,8 @@ def test_state_files_are_resources(project): return listed, file[0].text, folder[0].text, archived[0].text, blocked[0].text listed, file_text, folder_text, archived_text, blocked_text = asyncio.run(go()) - assert "gcontext://modules/m/index.md" in listed + assert any(u.startswith("agent://") and u.endswith("/modules/m") for u in listed) + assert any(u.startswith("agent://") and "modules" not in u and "connections" not in u for u in listed) assert not any("secrets.env" in u for u in listed) assert not any(u.startswith("gcontext://archive/") for u in listed) assert file_text == "topic notes"