commit 7d834ac98406d1f6ba7578556b239ed8a24125fb
Author: Jens Kristoffersson <jenkr@vgregion.se>
AuthorDate: Tue Sep 8 15:25:02 2026 +0200
Commit: Jens Kristoffersson <jenkr@vgregion.se>
CommitDate: Tue Sep 8 15:25:02 2026 +0200
Subfloden (fas 2): MCP, export, assist och webui-app
- rpa/mcp_server.py: save_flow/validate_flow tar 'subflows'. _as_graph baddar in
dem. Server-instruktionen namner call_flow + subflows.
- rpa/export.py: exporterade scriptet baddar in SUBFLOWS och skickar dem till
run_flow.
- rpa/assist.py: befintligt flode + emit_flow-svaret behaller 'subflows'
(systemprompten dokumenterar dem redan via step_reference_markdown).
- webui/app.py: Flow-modellen far 'subflows', RunOptions far 'subflow'
(-> entry_subflow), assist-endpointen skickar med subflows.
- Tester: assist behaller subflows, exporterat script baddar in SUBFLOWS. 108 grona.
---
rpa/assist.py | 3 +++
rpa/export.py | 5 ++++-
rpa/mcp_server.py | 28 ++++++++++++++++++----------
tests/test_assist.py | 17 +++++++++++++++++
tests/test_export.py | 23 +++++++++++++++++++++++
webui/app.py | 5 +++++
6 files changed, 70 insertions(+), 11 deletions(-)
diff --git a/rpa/assist.py b/rpa/assist.py
index 70528f9..62ca054 100644
--- a/rpa/assist.py
+++ b/rpa/assist.py
@@ -114,6 +114,7 @@ def generate_flow(instruction: str, current_flow: dict | None = None, *, model:
"edges": current_flow.get("edges", []),
"start": current_flow.get("start"),
"settings": current_flow.get("settings", {}),
+ "subflows": current_flow.get("subflows", []),
},
ensure_ascii=False,
indent=2,
@@ -145,6 +146,8 @@ def generate_flow(instruction: str, current_flow: dict | None = None, *, model:
}
if flow.get("start"):
normalized["start"] = flow["start"]
+ if flow.get("subflows"):
+ normalized["subflows"] = flow["subflows"]
errors = schema.validate_graph(normalized, strict=True)
if not errors:
normalized["explanation"] = str(flow.get("explanation", "")).strip()
diff --git a/rpa/export.py b/rpa/export.py
index 236dfd4..959b48d 100644
--- a/rpa/export.py
+++ b/rpa/export.py
@@ -35,6 +35,8 @@ EDGES = {edges_json}
START = {start_json}
+SUBFLOWS = {subflows_json}
+
SETTINGS = {settings_json}
INPUTS = {inputs_json}
@@ -79,7 +81,7 @@ def main() -> None:
settings["headless"] = True
run_flow(
- {{"format": "graph", "nodes": NODES, "edges": EDGES, "start": START}},
+ {{"format": "graph", "nodes": NODES, "edges": EDGES, "start": START, "subflows": SUBFLOWS}},
log=print,
download_dir=config.DOWNLOAD_DIR,
close_mode=args.close_mode,
@@ -106,6 +108,7 @@ def generate_standalone_script(name: str, flow: dict) -> str:
nodes_json=src(g.get("nodes", [])),
edges_json=src(g.get("edges", [])),
start_json=src(g.get("start")),
+ subflows_json=src(flow.get("subflows", [])),
settings_json=src(flow.get("settings", {})),
inputs_json=src(flow.get("inputs", [])),
input_sets_json=src(flow.get("input_sets", {})),
diff --git a/rpa/mcp_server.py b/rpa/mcp_server.py
index fd65c34..030a6ce 100644
--- a/rpa/mcp_server.py
+++ b/rpa/mcp_server.py
@@ -35,8 +35,10 @@ mcp = MCPServer(
instructions=(
"Bygg och kör webbautomationsflöden. Anropa describe_steps() först för att "
"se alla nodtyper, fält och handtagsmodellen. Ett flöde är en graf: "
- "nodes (platta noder) + edges (kanter). Flöden sparas som JSON och körs i "
- "en synlig webbläsare mot en beständig inloggningssession."
+ "nodes (platta noder) + edges (kanter). En 'call_flow'-nod kör ett annat "
+ "flöde eller en lokal subgraf (flödesnyckeln 'subflows') som ett steg. "
+ "Flöden sparas som JSON och körs i en synlig webbläsare mot en beständig "
+ "inloggningssession."
),
)
@@ -95,11 +97,14 @@ def get_flow(name: str) -> dict:
return {"error": str(exc)}
-def _as_graph(nodes, edges, start, settings, steps) -> dict:
+def _as_graph(nodes, edges, start, settings, steps, subflows=None) -> dict:
"""Bygg ett flödesdict från antingen nodes/edges eller det gamla steps-formatet."""
if nodes is not None:
- return {"format": "graph", "nodes": nodes, "edges": edges or [],
- "start": start, "settings": settings or {}}
+ g = {"format": "graph", "nodes": nodes, "edges": edges or [],
+ "start": start, "settings": settings or {}}
+ if subflows:
+ g["subflows"] = subflows
+ return g
return flow_to_graph({"steps": steps or [], "settings": settings or {}})
@@ -111,12 +116,14 @@ def save_flow(
start: str | None = None,
settings: dict | None = None,
steps: list | None = None,
+ subflows: list | None = None,
) -> dict:
"""Skapar eller skriver över ett flöde. Ange grafen som 'nodes' + 'edges'
- (se describe_steps för nodtyper och handtag). Det gamla 'steps'-formatet
- accepteras också och migreras. Returnerar valideringsfel utan att spara om
- flödet är ogiltigt."""
- g = _as_graph(nodes, edges, start, settings, steps)
+ (se describe_steps för nodtyper och handtag). 'subflows' = lokala namngivna
+ subgrafer som call_flow-noder kan anropa (target '#<id>'). Det gamla
+ 'steps'-formatet accepteras också och migreras. Returnerar valideringsfel
+ utan att spara om flödet är ogiltigt."""
+ g = _as_graph(nodes, edges, start, settings, steps, subflows)
errors = schema.validate_graph(g, strict=True)
if errors:
return {"ok": False, "errors": errors}
@@ -146,9 +153,10 @@ def validate_flow(
start: str | None = None,
settings: dict | None = None,
steps: list | None = None,
+ subflows: list | None = None,
) -> dict:
"""Validerar ett flöde (grafform) utan att spara det."""
- g = _as_graph(nodes, edges, start, settings, steps)
+ g = _as_graph(nodes, edges, start, settings, steps, subflows)
errors = schema.validate_graph(g, strict=True)
return {"valid": not errors, "errors": errors}
diff --git a/tests/test_assist.py b/tests/test_assist.py
index a454071..f100cd9 100644
--- a/tests/test_assist.py
+++ b/tests/test_assist.py
@@ -50,6 +50,23 @@ def test_generate_flow_happy_path(monkeypatch):
assert result["explanation"] == "Går till example.com."
+def test_generate_flow_keeps_subflows(monkeypatch):
+ _patch(monkeypatch, [{
+ "nodes": [{"id": "n1", "type": "call_flow", "target": "#hjalp"}],
+ "edges": [],
+ "start": "n1",
+ "settings": {},
+ "subflows": [{
+ "id": "hjalp", "name": "Hjälp",
+ "nodes": [{"id": "s1", "type": "wait", "seconds": 1}], "edges": [],
+ }],
+ "explanation": "Anropar en subgraf.",
+ }])
+ result = assist.generate_flow("gör en subgraf och anropa den")
+ assert result["subflows"][0]["id"] == "hjalp"
+ assert result["nodes"][0]["type"] == "call_flow"
+
+
def test_generate_flow_repairs_once(monkeypatch):
client = _patch(monkeypatch, [
{"nodes": [{"id": "n1", "type": "goto"}], "edges": [], "settings": {}}, # url saknas -> ogiltigt
diff --git a/tests/test_export.py b/tests/test_export.py
index 23f611e..52c6536 100644
--- a/tests/test_export.py
+++ b/tests/test_export.py
@@ -12,6 +12,16 @@ FLOW = {
"input_sets": {"Test": {"org_id": "9"}, "Prod": {"org_id": "1"}},
}
+GRAPH_FLOW = {
+ "format": "graph",
+ "nodes": [{"id": "n1", "type": "call_flow", "target": "#sub"}],
+ "edges": [],
+ "start": "n1",
+ "settings": {},
+ "subflows": [{"id": "sub", "name": "S",
+ "nodes": [{"id": "s1", "type": "wait", "seconds": 1}], "edges": []}],
+}
+
def _script_module(name="flode"):
src = generate_standalone_script(name, FLOW)
@@ -48,3 +58,16 @@ def test_build_initial_vars_errors():
mod.build_initial_vars("Saknas", [])
with pytest.raises(SystemExit):
mod.build_initial_vars(None, ["utan_likhetstecken"])
+
+
+def test_script_embeds_subflows():
+ src = generate_standalone_script("m", GRAPH_FLOW)
+ ast.parse(src)
+ mod = types.ModuleType("exported")
+ body = "\n".join(
+ line for line in src.splitlines()
+ if not line.startswith(("import argparse", "from rpa", " main()", 'if __name__'))
+ )
+ exec(compile(body, "<exported>", "exec"), mod.__dict__) # noqa: S102
+ assert mod.SUBFLOWS[0]["id"] == "sub"
+ assert mod.NODES[0]["type"] == "call_flow"
diff --git a/webui/app.py b/webui/app.py
index 381c2e5..235c8e8 100644
--- a/webui/app.py
+++ b/webui/app.py
@@ -33,11 +33,13 @@ class Flow(BaseModel):
layout: dict = {}
inputs: list = []
input_sets: dict = {}
+ subflows: list | None = None
class RunOptions(BaseModel):
close_mode: str = "always"
initial_vars: dict = {}
+ subflow: str | None = None
class AssistRequest(BaseModel):
@@ -47,6 +49,7 @@ class AssistRequest(BaseModel):
start: str | None = None
steps: list = []
settings: dict = {}
+ subflows: list = []
class CaptureRequest(BaseModel):
@@ -255,6 +258,7 @@ def assist_flow(name: str, req: AssistRequest) -> dict:
"start": req.start,
"steps": req.steps,
"settings": req.settings,
+ "subflows": req.subflows,
})
try:
result = assist.generate_flow(req.instruction, current_flow=current)
@@ -279,6 +283,7 @@ def run_flow_endpoint(name: str, options: RunOptions = RunOptions()) -> dict:
close_mode=close_mode,
settings=flow.get("settings", {}),
initial_vars=initial_vars,
+ entry_subflow=options.subflow,
)
return {"run_id": run_id}