From d556ba55ec370f34ace373305a1a5f5b43b184f4 Mon Sep 17 00:00:00 2001 From: Attila Toth Date: Sun, 21 Jun 2026 10:41:29 +0200 Subject: [PATCH 1/4] refactor: rename submit_output to finish_step --- AGENTS.md | 4 +-- README.md | 2 +- docs/glossary.md | 6 ++-- progi/db.py | 21 +++++++----- progi/mcp_server.py | 83 +++++++++++++++------------------------------ progi/models.py | 2 +- 6 files changed, 46 insertions(+), 72 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 27a5666..edd147a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -159,10 +159,8 @@ if (resp.ok) { uv sync --extra dev # deps + just uv run just install # + vendored JS, Tailwind CLI uv run just build # compile web/static/style.css -uv run just dev # web app with autoreload -uv run just mcp # MCP server only (stdio) +uv run just dev # MCP server over SSE (connect via http://127.0.0.1:8001/sse) uv run just migrate "msg" && uv run just upgrade # Alembic migration -uv run just seed # load Blog Post workflow + sample task uv run python -m pytest # tests (do NOT use `uv run pytest` — a system pytest may shadow it) uv run ruff check progi # lint ``` diff --git a/README.md b/README.md index 80c45bb..f622ac0 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ Tweak playbooks in Progi Monitoring between runs. Because workflows live in a da | `list_tasks` | List tasks, optionally filtered by status and/or workflow | | `start_or_continue_task` | Main work-loop entry point — starts or resumes a task and returns the current step's playbook, input data, and output spec | | `update_progress_notes` | Overwrite a task's progress notes (mid-step save point) | -| `submit_output` | Mark the current step complete, store its output, and advance to the next step (or mark done) | +| `finish_step` | Mark the current step complete, store its output, and advance to the next step (or mark done) | ### Workflow authoring diff --git a/docs/glossary.md b/docs/glossary.md index e1243eb..0c08494 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -16,7 +16,7 @@ Terms used across the progi codebase, README, and documentation. **edge** — A directed connection between two steps. Defines execution flow. Can be conditional (evaluated against the step's `output`) or unconditional. Supports branching. Stored in the `step_edges` table. -**agent** — The AI assistant running inside the user's MCP harness (Claude Code, Cursor, etc.). Reads the playbook, performs the work, and calls `submit_output` to advance the task. +**agent** — The AI assistant running inside the user's MCP harness (Claude Code, Cursor, etc.). Reads the playbook, performs the work, and calls `finish_step` to advance the task. --- @@ -44,7 +44,7 @@ Terms used across the progi codebase, README, and documentation. **input_data** — The resolved, concrete data passed to a step instance when it activates. Derived from `input_spec`; may pull from a prior step's `output`. Stored as JSON on `step_instances.input_data`. -**output** — The actual deliverable submitted by the agent via `submit_output`. Stored as JSON on `step_instances.output`. Used to resolve the next step's `input_data` and to evaluate edge `conditions`. +**output** — The actual deliverable submitted by the agent via `finish_step`. Stored as JSON on `step_instances.output`. Used to resolve the next step's `input_data` and to evaluate edge `conditions`. **source** — Field inside `input_spec`. Either `"static"` (data comes from task creation) or `"previous_step_output"` (data comes from a prior step's `output`). @@ -68,7 +68,7 @@ Terms used across the progi codebase, README, and documentation. ## Work loop -**work loop** — The main runtime cycle: `start_or_continue_task` → agent reads playbook and `input_data` → agent works → `submit_output` → next step activates (or task is done). Runs entirely inside the MCP harness. +**work loop** — The main runtime cycle: `start_or_continue_task` → agent reads playbook and `input_data` → agent works → `finish_step` → next step activates (or task is done). Runs entirely inside the MCP harness. **lazy creation** — Step instances are created only when a step is activated, not upfront. Avoids instantiating branches that are never taken. diff --git a/progi/db.py b/progi/db.py index bb884bc..847b7e1 100644 --- a/progi/db.py +++ b/progi/db.py @@ -191,16 +191,18 @@ def _start_step(conn, workflow_id: int) -> dict[str, Any]: return dict(row) -def _evaluate_condition(condition: dict | None, output: dict) -> bool: +def _evaluate_condition(condition: dict | None, output: dict | str) -> bool: """Return True if the edge condition matches the step output. A null condition always matches (unconditional / default edge). Supported operators: eq, neq, in, not_in. + Plain-text (str) outputs are treated as {"value": output} for condition matching. """ if condition is None: return True field = condition["field"] - value = output.get(field) + output_dict = {"value": output} if isinstance(output, str) else output + value = output_dict.get(field) op = condition["operator"] if op == "eq": return value == condition["value"] @@ -213,6 +215,7 @@ def _evaluate_condition(condition: dict | None, output: dict) -> bool: return False + def _resolve_next_step(conn, current_step_id: int, output: dict) -> dict[str, Any] | None: """Return the next step dict by evaluating outgoing edges, or None if terminal. @@ -242,9 +245,10 @@ def _resolve_next_step(conn, current_step_id: int, output: dict) -> dict[str, An ) return dict(row) + output_desc = list(output.keys()) if isinstance(output, dict) else repr(output[:80]) raise ValueError( f"No outgoing edge condition matched for step {current_step_id}. " - f"Output fields: {list(output.keys())}. " + f"Output fields: {output_desc}. " f"Check that the output includes the field referenced by at least one edge condition, " f"or add an unconditional (null condition) fallback edge." ) @@ -267,7 +271,7 @@ def save_workflow( { "name": str, "description": str, - "process": [ + "steps": [ {"order": int, "name": str, "input_spec": {...}, "output_spec": {...}} ], "edges": [ # optional; auto-generated if absent @@ -290,7 +294,8 @@ def save_workflow( ).inserted_primary_key[0] step_rows: list[dict[str, Any]] = [] - for step in sorted(skeleton_json["process"], key=lambda s: s["order"]): + _steps_list = skeleton_json.get("process") or skeleton_json.get("steps") or [] + for step in sorted(_steps_list, key=lambda s: s["order"]): step_id = conn.execute( sa.insert(steps).values( workflow_id=wf_id, @@ -726,7 +731,7 @@ def update_progress_notes(cfg: Config, task_id: int, notes: str) -> dict[str, An def submit_output( - cfg: Config, task_id: int, output: dict[str, Any], task_name: str | None = None + cfg: Config, task_id: int, output: dict[str, Any] | str, task_name: str | None = None ) -> dict[str, Any]: """Complete the current step, evaluate edge conditions, then advance or finish. @@ -822,7 +827,7 @@ def submit_output( source_output = output next_input_data = { - "value": source_output.get("value", source_output), + "value": source_output if isinstance(source_output, str) else source_output.get("value", source_output), "from_step": from_step_name or current_step["name"], } else: @@ -942,7 +947,7 @@ def export_workflow(cfg: Config, workflow_id: int) -> dict[str, Any]: return { "name": wf["name"], "description": wf["description"], - "process": [ + "steps": [ { "order": s["order"], "name": s["name"], diff --git a/progi/mcp_server.py b/progi/mcp_server.py index c45fc73..24524d0 100644 --- a/progi/mcp_server.py +++ b/progi/mcp_server.py @@ -5,7 +5,7 @@ here. Two tool families: - **Work loop**: create_task, list_tasks, - start_or_continue_task, update_progress_notes, submit_output. + start_or_continue_task, update_progress_notes, finish_step. - **Workflow authoring**: get_process_skeleton_prompt, get_playbook_authoring_prompt, save_workflow, list_workflows. @@ -40,7 +40,6 @@ def _monitoring_url(path: str = "") -> str: _PROMPTS_DIR = Path(__file__).parent / "prompts" _WORKFLOW_SKELETON_MD = _PROMPTS_DIR / "workflow_skeleton.md" -_PLAYBOOK_MD = _PROMPTS_DIR / "playbook.md" # --------------------------------------------------------------------------- @@ -89,7 +88,7 @@ def start_or_continue_task(task_id: int) -> dict: output_spec (the expected format/type of the deliverable), the playbook markdown, and progress_notes (if any). - Before calling submit_output, verify that your output satisfies output_spec + Before calling finish_step, verify that your output satisfies output_spec (correct type, meets constraints, includes any fields referenced by branching conditions). @@ -110,8 +109,8 @@ def update_progress_notes(task_id: int, notes: str) -> dict: return db.update_progress_notes(_cfg, task_id, notes) -@mcp.tool(title="Submit Output") -def submit_output(task_id: int, output: dict | str, task_name: str = "") -> dict: +@mcp.tool(title="Finish Step") +def finish_step(task_id: int, output: dict | str, task_name: str = "") -> dict: """Mark the current step complete, store its output, and advance. Either returns the next step's info (name + playbook, so the agent can @@ -124,11 +123,17 @@ def submit_output(task_id: int, output: dict | str, task_name: str = "") -> dict IMPORTANT — approval gate: if start_or_continue_task returned current_step.requires_approval = true for this step, you MUST present the output to the user and ask for explicit approval BEFORE calling this tool. - Only call submit_output once the user has confirmed they are happy with the + Only call finish_step once the user has confirmed they are happy with the output. If they request changes, make them first, then ask again. """ if isinstance(output, str): - output = json.loads(output) + if output.strip(): + try: + output = json.loads(output) + except json.JSONDecodeError: + pass # plain text output — store as-is + else: + output = {} return db.submit_output(_cfg, task_id, output, task_name or None) @@ -156,61 +161,27 @@ def _library_block() -> str: @mcp.tool(title="Get Process Skeleton Prompt") def get_process_skeleton_prompt() -> str: - """Return the Pass 1 system prompt for authoring a new workflow's skeleton. + """Return the authoring prompt for designing a new workflow. - The harness uses it to help the user convert a plain-language workflow - description into a structured process skeleton (steps with input/output specs, - no playbooks yet). + The returned prompt covers both passes: + - Pass 1: work with the user to produce and approve a skeleton JSON + (steps with input/output specs). + - Pass 2: once the user approves, generate all step playbooks silently + (no further tool calls needed) and call save_workflow with everything. """ return _WORKFLOW_SKELETON_MD.read_text(encoding="utf-8") + _library_block() -@mcp.tool(title="Get Playbook Authoring Prompt") -def get_playbook_authoring_prompt(step_id: int) -> str: - """Return the Pass 2 system prompt for authoring a step's playbook. - - Workflow context (the full process, this step's position, and its - input/output specs) is injected at the top of the prompt template. - """ - ctx = db.get_playbook_authoring_context(_cfg, step_id) - wf = ctx["workflow"] - step = ctx["step"] - siblings = ctx["siblings"] - - process_list = " → ".join( - f"**{s['name']}**" if s["name"] == step["name"] else s["name"] for s in siblings - ) - - requires_approval = bool(step.get("requires_approval", False)) - approval_status = ( - "YES — agent must show output to user and get approval before submitting" - if requires_approval - else "NO — agent may submit immediately" - ) - - context_block = f""" -## Workflow Context - -- **Workflow**: {wf["name"]} — {wf["description"]} -- **Full process**: {process_list} -- **This step**: **{step["name"]}** - - input_spec: {json.dumps(step["input_spec"], indent=2)} - - output_spec: {json.dumps(step["output_spec"], indent=2)} - - requires_approval (current value): **{approval_status}** - ---- - -""" - library = _library_block() - separator = "\n\n---\n\n" if library else "" - return context_block + library + separator + _PLAYBOOK_MD.read_text(encoding="utf-8") - - @mcp.tool(title="Save Workflow") def save_workflow(skeleton: dict, playbooks_by_step: dict) -> dict: """Persist a new workflow, its steps, and playbooks. - skeleton: the JSON object produced by Pass 1 (process skeleton prompt). + Intended call sequence: + 1. get_process_skeleton_prompt → work with user to produce and approve skeleton JSON. + 2. Generate all step playbooks silently using the Pass 2 instructions in that prompt. + 3. Call save_workflow(skeleton, playbooks_by_step) with all playbooks collected. + + skeleton: the workflow skeleton dict (name, description, steps[]). playbooks_by_step: mapping of step name → playbook markdown string. After saving, always show the user the monitoring_url from the response. @@ -235,10 +206,10 @@ def list_workflows() -> dict: -def run(cfg: Config | None = None) -> None: - """Run the MCP server over stdio. Blocks until the client disconnects.""" +def run(cfg: Config | None = None, transport: str = "stdio", **transport_kwargs) -> None: + """Run the MCP server. Blocks until the client disconnects (stdio) or killed (sse/http).""" global _cfg if cfg is not None: _cfg = cfg configure_logging() # routes logs to stderr, never stdout - mcp.run() # default transport is stdio + mcp.run(transport=transport, **transport_kwargs) diff --git a/progi/models.py b/progi/models.py index 015ab4d..e23a064 100644 --- a/progi/models.py +++ b/progi/models.py @@ -39,7 +39,7 @@ sa.Column("input_spec", sa.JSON, nullable=False), sa.Column("output_spec", sa.JSON, nullable=False), # When True, the agent must present the step output to the user and get - # explicit approval before calling submit_output. + # explicit approval before calling finish_step. sa.Column("requires_approval", sa.Boolean, nullable=False, server_default="0"), sa.Column( "library_entry_id", From 0385d70793c213d3ec5e80c287492f82ac1b5c4d Mon Sep 17 00:00:00 2001 From: Attila Toth Date: Sun, 21 Jun 2026 10:41:39 +0200 Subject: [PATCH 2/4] refactor: rename process key to steps in workflow JSON --- progi/prompts/workflow_skeleton.md | 57 ++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/progi/prompts/workflow_skeleton.md b/progi/prompts/workflow_skeleton.md index 48774c2..b3e7080 100644 --- a/progi/prompts/workflow_skeleton.md +++ b/progi/prompts/workflow_skeleton.md @@ -28,7 +28,7 @@ Return exactly one JSON object of this shape: { "name": "Blog Post", "description": "Workflow for researching, writing, editing, and publishing a blog post.", - "process": [ + "steps": [ { "order": 1, "name": "Research", @@ -56,7 +56,7 @@ For a branching workflow, the edges express the routing logic: { "name": "Content Review", "description": "Write and review before publishing, with an optional fast-track.", - "process": [ + "steps": [ {"order": 1, "name": "Draft", "input_spec": {"description": "Topic.", "source": "static", "from_step": null}, "output_spec": {"type": "file", "description": "Draft", "constraints": "include review_needed boolean"}}, {"order": 2, "name": "Edit", "input_spec": {"description": "Draft.", "source": "previous_step_output", "from_step": "Draft"}, "output_spec": {"type": "file", "description": "Edited doc", "constraints": "markdown"}}, {"order": 3, "name": "Publish", "input_spec": {"description": "Doc to publish.", "source": "previous_step_output", "from_step": "Edit"}, "output_spec": {"type": "url", "description": "Published URL", "constraints": "valid URL"}} @@ -104,9 +104,52 @@ For a branching workflow, the edges express the routing logic: iterations — the runtime always uses the most recent completed output for the named `from_step`. -Do not write playbooks in this pass. Once the user approves the skeleton, it is -saved and Pass 2 authors each step's playbook. +## After the user approves the skeleton -**After approval, do not output the skeleton JSON to the user.** The JSON is an -internal artifact for tool calls only. Acknowledge approval briefly and proceed -to the next pass. +Once the user approves the skeleton JSON, generate all step playbooks silently +(no user interaction needed for this — the Pass 2 instructions are below). +Then call `save_workflow` with the skeleton and the completed playbooks map. + +**Do not output the skeleton JSON to the user.** The JSON is an internal +artifact for tool calls only. Acknowledge approval briefly, generate playbooks +silently, then call `save_workflow`. + +--- + +# Pass 2: Playbook Authoring + +You are authoring the **playbook** for each step of the workflow you just +designed. A playbook is one self-contained markdown document that the AI +**agent** (the assistant inside the user's harness — Claude Code, Cursor, etc.) +will follow to perform that step at runtime. + +For each step in the skeleton, write a playbook against its `input_spec`, +`output_spec`, and `requires_approval` flag. Collect all playbooks into the +`playbooks_by_step` map (step name → markdown string) and pass them to +`save_workflow`. + +## What a good playbook contains + +Write a markdown document that includes: + +1. **A heading** naming the step and its role in the larger workflow. +2. **Input** — what the step starts from. If `input_spec.source` is + `previous_step_output`, the prior step's output is available; say how to find + or use it. If `static`, describe what to ask the user for. +3. **Working instructions** — the concrete actions the agent takes to produce + the deliverable. +4. **Human-involvement points** — there is no separate "human step": every step + is run by the agent, and the playbook decides when to pull the human in. Be + explicit, e.g. "ask the user to confirm tone before drafting". The + `requires_approval` flag on the step controls whether the agent must present + the final output to the user and receive explicit sign-off before calling + `finish_step`. If it is true, the playbook should describe what to show the + user and how to handle requested changes before submitting. +5. **Output** — exactly what deliverable satisfies `output_spec` (a file path, a + URL, or text) and how the agent reports it back. The agent submits this via + `finish_step`, which advances the task to the next step. + +## Style +- Address the agent in the second person ("You are working on…"). +- Be specific and actionable; no fluff. +- Keep each playbook to a single markdown document — no separate files. From 09d7473c990d4d5fddc6cc9004d7cc925a0a91fc Mon Sep 17 00:00:00 2001 From: Attila Toth Date: Sun, 21 Jun 2026 10:41:47 +0200 Subject: [PATCH 3/4] refactor: collapse authoring into single tool, remove get_playbook_authoring_prompt --- progi/prompts/playbook.md | 38 -------------------------------------- 1 file changed, 38 deletions(-) delete mode 100644 progi/prompts/playbook.md diff --git a/progi/prompts/playbook.md b/progi/prompts/playbook.md deleted file mode 100644 index a91af1b..0000000 --- a/progi/prompts/playbook.md +++ /dev/null @@ -1,38 +0,0 @@ -# System Prompt — Pass 2: Playbook Authoring - -You are authoring the **playbook** for a single step of a workflow. -A playbook is one self-contained markdown document that the AI **agent** (the -assistant inside the user's harness — Claude Code, Cursor, etc.) will follow to -perform this step at runtime. - -The workflow context (the full process, this step's position, and its -`input_spec` / `output_spec`) is injected above this prompt. Author the playbook -against those specs. - -## What a good playbook contains - -Write a markdown document that includes: - -1. **A heading** naming the step and its role in the larger workflow. -2. **Input** — what the step starts from. If `input_spec.source` is - `previous_step_output`, the prior step's output is available; say how to find - or use it. If `static`, describe what to ask the user for. -3. **Working instructions** — the concrete actions the agent takes to produce - the deliverable. -4. **Human-involvement points** — there is no separate "human step": every step - is run by the agent, and the playbook decides when to pull the human in. Be - explicit, e.g. "ask the user to confirm tone before drafting". The `requires_approval` flag on - this step (shown in the context above) controls whether the agent must present - the final output to the user and receive explicit sign-off before calling - `submit_output`. If it is true, the playbook should describe what to show the - user and how to handle requested changes before submitting. -5. **Output** — exactly what deliverable satisfies `output_spec` (a file path, a - URL, or text) and how the agent reports it back. The agent submits this via - `submit_output`, which advances the task to the next step. - -## Style -- Address the agent in the second person ("You are working on…"). -- Be specific and actionable; no fluff. -- Keep it to a single markdown document — no separate files. - -Return only the playbook markdown for **this** step. From e222a0c862e183711f073dc12c07cd85b63edb63 Mon Sep 17 00:00:00 2001 From: Attila Toth Date: Sun, 21 Jun 2026 10:41:52 +0200 Subject: [PATCH 4/4] feat: SSE transport support via --transport flag --- justfile | 22 +++++----------------- progi/cli.py | 12 ++++++++++-- 2 files changed, 15 insertions(+), 19 deletions(-) diff --git a/justfile b/justfile index c7e502c..8e9838a 100644 --- a/justfile +++ b/justfile @@ -5,7 +5,7 @@ set dotenv-load # Quickstart: # just install # python deps + vendored JS + tailwind CLI # just build # compile CSS -# just dev # run web app with reload +# just dev # run MCP server over SSE # # Versions are pinned; bump them here. @@ -20,25 +20,17 @@ marked_version := "18.0.5" # Dev # --------------------------------------------------------------------------- -# Run the web app with autoreload + CSS watch (web only; MCP not involved here). -dev: - #!/usr/bin/env bash - set -euo pipefail - cd frontend && ./tailwindcss -i input.css -o ../progi/web/static/style.css --watch & - trap "kill 0" EXIT - uv run python -m uvicorn progi.web.app:app --reload - # Run the web app with autoreload only (no CSS watch). uvicorn: uv run python -m uvicorn progi.web.app:app --reload -# Watch and rebuild CSS while developing (run alongside `just dev`). +# Watch and rebuild CSS while developing (run alongside `just uvicorn`). watch-css: cd frontend && ./tailwindcss -i input.css -o ../progi/web/static/style.css --watch -# Run the MCP server (stdio) only -mcp: - uv run progi --no-web +# Run the MCP server over SSE (shared, persistent; clients connect via URL instead of spawning a process) +dev host="127.0.0.1" port="8001": + uv run progi --transport sse --mcp-host {{host}} --mcp-port {{port}} # --------------------------------------------------------------------------- # Install @@ -121,10 +113,6 @@ migrate message: upgrade: uv run alembic upgrade head -# Load the "Blog Post" workflow + a sample task (idempotent). -seed: - uv run python -m progi.seed - # --------------------------------------------------------------------------- # Quality # --------------------------------------------------------------------------- diff --git a/progi/cli.py b/progi/cli.py index 01a3ce0..f2f8ad1 100644 --- a/progi/cli.py +++ b/progi/cli.py @@ -57,6 +57,14 @@ def main() -> None: ) parser.add_argument("--web-host", default=None, help="Override web bind host.") parser.add_argument("--web-port", type=int, default=None, help="Override web port.") + parser.add_argument( + "--transport", + default="stdio", + choices=["stdio", "sse", "http"], + help="MCP transport (default: stdio).", + ) + parser.add_argument("--mcp-host", default="127.0.0.1", help="MCP bind host (sse/http only).") + parser.add_argument("--mcp-port", type=int, default=8001, help="MCP port (sse/http only).") args = parser.parse_args() base = load_config() @@ -76,8 +84,8 @@ def main() -> None: if not cfg.no_web: _start_web_in_thread(cfg) - # Foreground: MCP server over stdio. Blocks until the client disconnects. - mcp_server.run(cfg) + # Foreground: MCP server. Blocks until the client disconnects (stdio) or killed (sse/http). + mcp_server.run(cfg, transport=args.transport, host=args.mcp_host, port=args.mcp_port) if __name__ == "__main__":