Skip to content
Merged
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
4 changes: 1 addition & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 3 additions & 3 deletions docs/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down Expand Up @@ -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`).

Expand All @@ -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.

Expand Down
22 changes: 5 additions & 17 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
12 changes: 10 additions & 2 deletions progi/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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__":
Expand Down
21 changes: 13 additions & 8 deletions progi/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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.

Expand Down Expand Up @@ -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."
)
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"],
Expand Down
83 changes: 27 additions & 56 deletions progi/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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"


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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).

Expand All @@ -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
Expand All @@ -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)


Expand Down Expand Up @@ -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"""<!-- CONTEXT INJECTED BY MCP SERVER -->
## 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.
Expand All @@ -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)
2 changes: 1 addition & 1 deletion progi/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading