runxmd 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
runxmd/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """XMD — a runtime that executes documents.
2
+
3
+ v0.0.1: parse an .xmd document, run its workflows with inline-code plugins,
4
+ resolve and persist memory. The document becomes the system.
5
+ """
6
+
7
+ __version__ = "1.0.0"
runxmd/agent.py ADDED
@@ -0,0 +1,185 @@
1
+ """Agent engine (Layer 7) — goal -> tasks -> execute -> memory.
2
+
3
+ `runxmd agent` is **agent-author** mode: unlike `runxmd run` (which may only write
4
+ `runtime.*` memory, per field ownership), the agent command is authorized to
5
+ author `@tasks` generated from the `@goal`. The document grows itself.
6
+
7
+ Two execution modes (both built):
8
+
9
+ 1. **Explicit workflow link** — a task annotated `-> workflow_name` runs that
10
+ `@workflow` and is ticked on success. Default; safe, human-authored.
11
+ 2. **LLM-emitted steps** — for a task with no workflow link, the LLM generates
12
+ XMD steps which the agent then runs. Gated behind `--autonomous`, because it
13
+ executes model-generated commands and must be an explicit choice.
14
+
15
+ Planning (goal -> task list) uses the `@llm` plugin (Anthropic via stdlib
16
+ urllib; key from `ANTHROPIC_API_KEY`). With no key, planning degrades gracefully
17
+ and the agent still executes any pre-existing tasks (agent-in-the-loop mode).
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import re
22
+
23
+ from . import executor, plugins
24
+ from .parser import Section, Task, _parse_steps, parse, to_source
25
+
26
+ _MAX_TASKS = 8
27
+
28
+
29
+ def agent_run(
30
+ path: str,
31
+ replan: bool = False,
32
+ autonomous: bool = False,
33
+ model: str = None,
34
+ max_tokens: int = 1024,
35
+ dry_run: bool = False,
36
+ out=print,
37
+ ):
38
+ with open(path, encoding="utf-8") as f:
39
+ doc = parse(f.read())
40
+
41
+ goal_sec = doc.section("goal")
42
+ goal = (goal_sec.text if goal_sec else "").strip()
43
+ if not goal:
44
+ out("✗ no @goal to work from — add a @goal section.")
45
+ return doc
46
+ out(f"◆ goal: {goal}")
47
+
48
+ mem_sec = doc.section("memory")
49
+ memory = dict(mem_sec.memory) if mem_sec else {}
50
+ ctx = {"memory": memory}
51
+ tasks_sec = doc.section("tasks")
52
+ workflow_names = [w.name for w in doc.workflows() if w.name]
53
+
54
+ # 1. PLAN -----------------------------------------------------------------
55
+ if replan or not (tasks_sec and tasks_sec.tasks):
56
+ out("\n· planning tasks from goal …")
57
+ planned = _plan(goal, workflow_names, model, max_tokens, out)
58
+ if planned:
59
+ if tasks_sec is None:
60
+ tasks_sec = Section(kind="tasks")
61
+ doc.sections.append(tasks_sec)
62
+ if replan:
63
+ tasks_sec.tasks = []
64
+ tasks_sec.tasks.extend(Task(done=False, text=t) for t in planned)
65
+ out(f"· planned {len(planned)} task(s)")
66
+ for t in planned:
67
+ out(f" - {t}")
68
+ else:
69
+ out("· no plan produced — executing any existing tasks instead")
70
+
71
+ # 2. EXECUTE --------------------------------------------------------------
72
+ open_tasks = [t for t in (tasks_sec.tasks if tasks_sec else []) if not t.done]
73
+ if not open_tasks:
74
+ out("\n· no open tasks to execute")
75
+ done = 0
76
+ for task in open_tasks:
77
+ target = _target_workflow(task.text)
78
+ wf = doc.section("workflow", target) if target else None
79
+ if wf is not None:
80
+ out(f"\n● task: {task.text}")
81
+ if dry_run:
82
+ out(f" → would run workflow '{target}'")
83
+ continue
84
+ ok = executor.run_workflow(wf, memory, ctx, out)
85
+ elif autonomous:
86
+ out(f"\n● task (autonomous): {task.text}")
87
+ if dry_run:
88
+ out(" → would ask the LLM to generate + run steps")
89
+ continue
90
+ ok = _exec_via_llm(task.text, goal, memory, ctx, model, max_tokens, out)
91
+ else:
92
+ out(
93
+ f"\n○ task skipped — no '-> workflow' link "
94
+ f"(use --autonomous to let the LLM run it): {task.text}"
95
+ )
96
+ continue
97
+ if ok:
98
+ task.done = True
99
+ done += 1
100
+ else:
101
+ out(f" ! left open (failed): {task.text}")
102
+
103
+ # 3. MEMORY + write-back --------------------------------------------------
104
+ if dry_run:
105
+ out("\n· dry run — nothing written")
106
+ return doc
107
+ memory["runtime.agent_runs"] = int(memory.get("runtime.agent_runs", 0) or 0) + 1
108
+ memory["runtime.tasks_done_last"] = done
109
+ hooks_sec = doc.section("on_done")
110
+ if hooks_sec:
111
+ executor._apply_hooks(hooks_sec.hooks, memory, out)
112
+ if mem_sec is None:
113
+ mem_sec = Section(kind="memory")
114
+ doc.sections.append(mem_sec)
115
+ mem_sec.memory = memory
116
+ with open(path, "w", encoding="utf-8") as f:
117
+ f.write(to_source(doc))
118
+ out(f"\n✓ agent run complete — {done} task(s) executed; memory updated")
119
+ return doc
120
+
121
+
122
+ # --------------------------------------------------------------------------- #
123
+ # Helpers
124
+ # --------------------------------------------------------------------------- #
125
+ def _target_workflow(text: str):
126
+ if "->" in text:
127
+ return _slug(text.rsplit("->", 1)[1])
128
+ return None
129
+
130
+
131
+ def _slug(s: str) -> str:
132
+ return re.sub(r"[^a-zA-Z0-9_]+", "_", s.strip()).strip("_")
133
+
134
+
135
+ def _plan(goal, workflow_names, model, max_tokens, out):
136
+ llm = plugins.get("llm")
137
+ if llm is None:
138
+ return []
139
+ wf_hint = ""
140
+ if workflow_names:
141
+ wf_hint = (
142
+ "\nExisting workflows you may link a task to with ' -> name': "
143
+ + ", ".join(workflow_names)
144
+ )
145
+ prompt = (
146
+ "You are a planning assistant for the XMD document runtime. Given a "
147
+ f"project GOAL, output up to {_MAX_TASKS} concrete, ordered tasks — one "
148
+ "per line, no numbering, no prose. If a task is fulfilled by an existing "
149
+ "workflow, append ' -> workflow_name'." + wf_hint + "\n\nGOAL:\n" + goal
150
+ )
151
+ res = llm({"prompt": prompt, "model": model, "max_tokens": max_tokens}, {})
152
+ if not res.ok:
153
+ out(f" · planner unavailable: {res.error}")
154
+ return []
155
+ tasks = []
156
+ for line in res.output.splitlines():
157
+ clean = line.strip().lstrip("-*0123456789. \t").strip()
158
+ if clean:
159
+ tasks.append(clean)
160
+ return tasks[:_MAX_TASKS]
161
+
162
+
163
+ def _exec_via_llm(task_text, goal, memory, ctx, model, max_tokens, out):
164
+ llm = plugins.get("llm")
165
+ if llm is None:
166
+ out(" · @llm unavailable — cannot generate steps")
167
+ return False
168
+ prompt = (
169
+ "You generate XMD workflow steps. For the TASK below, output ONLY steps "
170
+ "in this exact format (no prose, no code fences):\n"
171
+ "- @shell\n run: <command>\n"
172
+ "Allowed plugins: @shell, @python (run: block), @write (path, content), "
173
+ "@read (path), @http (url). Keep it minimal and safe.\n\n"
174
+ f"GOAL: {goal}\nTASK: {task_text}"
175
+ )
176
+ res = llm({"prompt": prompt, "model": model, "max_tokens": max_tokens}, {})
177
+ if not res.ok:
178
+ out(f" · step generation failed: {res.error}")
179
+ return False
180
+ steps = _parse_steps(res.output.splitlines())
181
+ if not steps:
182
+ out(" · LLM returned no runnable steps")
183
+ return False
184
+ out(f" · LLM generated {len(steps)} step(s)")
185
+ return executor.run_steps(steps, memory, ctx, out)
runxmd/cli.py ADDED
@@ -0,0 +1,102 @@
1
+ """XMD command-line interface (SPEC §5)."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import sys
7
+ from dataclasses import asdict
8
+
9
+ from . import __version__, executor
10
+ from .parser import parse
11
+
12
+
13
+ def _load(path: str):
14
+ with open(path, encoding="utf-8") as f:
15
+ return parse(f.read())
16
+
17
+
18
+ def main(argv=None) -> int:
19
+ sys.stdout.reconfigure(encoding="utf-8")
20
+ sys.stderr.reconfigure(encoding="utf-8")
21
+ p = argparse.ArgumentParser(prog="runxmd", description="runxmd — the XMD runtime v" + __version__)
22
+ p.add_argument("--version", action="version", version="runxmd " + __version__)
23
+ sub = p.add_subparsers(dest="cmd")
24
+
25
+ pr = sub.add_parser("run", help="execute workflow(s) and persist memory")
26
+ pr.add_argument("file")
27
+ pr.add_argument("--workflow", help="run only the named workflow")
28
+ pr.add_argument("--no-write", action="store_true", help="do not write memory back")
29
+
30
+ pw = sub.add_parser("watch", help="re-run the file whenever it changes")
31
+ pw.add_argument("file")
32
+ pw.add_argument("--workflow", help="run only the named workflow")
33
+ pw.add_argument("--interval", type=float, default=1.0, help="poll seconds")
34
+ pw.add_argument("--max-runs", type=int, default=0, help="stop after N runs (0=forever)")
35
+ pw.add_argument("--no-write", action="store_true", help="do not write memory back")
36
+
37
+ pa = sub.add_parser("agent", help="goal -> tasks -> execute -> memory (Layer 7)")
38
+ pa.add_argument("file")
39
+ pa.add_argument("--replan", action="store_true", help="regenerate tasks from the goal")
40
+ pa.add_argument("--autonomous", action="store_true",
41
+ help="let the LLM generate + RUN steps for tasks with no workflow link")
42
+ pa.add_argument("--model", help="LLM model id for planning/generation")
43
+ pa.add_argument("--max-tokens", type=int, default=1024)
44
+ pa.add_argument("--dry-run", action="store_true", help="plan/show without executing or writing")
45
+
46
+ pp = sub.add_parser("parse", help="print parsed structure as JSON")
47
+ pp.add_argument("file")
48
+
49
+ pv = sub.add_parser("validate", help="check the file parses; list sections")
50
+ pv.add_argument("file")
51
+
52
+ args = p.parse_args(argv)
53
+
54
+ if args.cmd == "run":
55
+ executor.run(args.file, workflow_name=args.workflow, write_back=not args.no_write)
56
+ elif args.cmd == "watch":
57
+ flush = lambda *a: print(*a, flush=True) # noqa: E731 — keep watch output live
58
+ executor.watch(
59
+ args.file,
60
+ workflow_name=args.workflow,
61
+ interval=args.interval,
62
+ max_runs=args.max_runs,
63
+ write_back=not args.no_write,
64
+ out=flush,
65
+ )
66
+ elif args.cmd == "agent":
67
+ from . import agent
68
+ agent.agent_run(
69
+ args.file,
70
+ replan=args.replan,
71
+ autonomous=args.autonomous,
72
+ model=args.model,
73
+ max_tokens=args.max_tokens,
74
+ dry_run=args.dry_run,
75
+ )
76
+ elif args.cmd == "parse":
77
+ print(json.dumps(asdict(_load(args.file)), indent=2))
78
+ elif args.cmd == "validate":
79
+ return _validate(args.file)
80
+ else:
81
+ p.print_help()
82
+ return 0
83
+
84
+
85
+ def _validate(path: str) -> int:
86
+ try:
87
+ doc = _load(path)
88
+ except Exception as e: # noqa: BLE001 — surface any parse failure to the user
89
+ print(f"✗ failed to parse {path}: {e}")
90
+ return 1
91
+ if not doc.sections:
92
+ print(f"✗ {path}: no sections found")
93
+ return 1
94
+ print(f"✓ {path} — title: {doc.title or '(none)'}")
95
+ for s in doc.sections:
96
+ name = f" {s.name}" if s.name else ""
97
+ print(f" @{s.kind}{name}")
98
+ return 0
99
+
100
+
101
+ if __name__ == "__main__":
102
+ sys.exit(main())
runxmd/executor.py ADDED
@@ -0,0 +1,151 @@
1
+ """Execution engine (SPEC §3-4).
2
+
3
+ Loads a document, runs its workflow(s) top-to-bottom, resolving memory
4
+ references per step, applies @on_done hooks, and writes memory back to the file.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import re
10
+ import time
11
+
12
+ from . import plugins
13
+ from .memory import substitute
14
+ from .parser import Document, parse, parse_scalar, to_source
15
+
16
+ # Field ownership (SPEC §4): the runtime may only write memory keys under this
17
+ # reserved namespace. Everything else is agent-owned and never touched by the
18
+ # runtime — this is what makes the two-author document safe instead of lucky.
19
+ RUNTIME_NAMESPACE = "runtime."
20
+
21
+ _SET_RE = re.compile(r"^set:\s*memory\.([a-zA-Z0-9_.]+)\s*=\s*(.+)$")
22
+
23
+
24
+ def run(
25
+ path: str,
26
+ workflow_name: str = None,
27
+ write_back: bool = True,
28
+ out=print,
29
+ ) -> Document:
30
+ with open(path, encoding="utf-8") as f:
31
+ doc = parse(f.read())
32
+
33
+ mem_sec = doc.section("memory")
34
+ memory = dict(mem_sec.memory) if mem_sec else {}
35
+ ctx = {"memory": memory}
36
+
37
+ workflows = doc.workflows()
38
+ if workflow_name:
39
+ workflows = [w for w in workflows if w.name == workflow_name]
40
+
41
+ if not workflows:
42
+ out("No workflow to run.")
43
+ for wf in workflows:
44
+ run_workflow(wf, memory, ctx, out)
45
+
46
+ hooks_sec = doc.section("on_done")
47
+ if hooks_sec:
48
+ _apply_hooks(hooks_sec.hooks, memory, out)
49
+
50
+ if write_back and mem_sec is not None:
51
+ mem_sec.memory = memory
52
+ with open(path, "w", encoding="utf-8") as f:
53
+ f.write(to_source(doc))
54
+ out("\n· memory written back")
55
+
56
+ return doc
57
+
58
+
59
+ def watch(
60
+ path: str,
61
+ workflow_name: str = None,
62
+ interval: float = 1.0,
63
+ max_runs: int = 0,
64
+ write_back: bool = True,
65
+ out=print,
66
+ ) -> None:
67
+ """Re-run the document whenever it changes on disk (SPEC §5, reactive seed).
68
+
69
+ Self-trigger guard: the mtime baseline is captured *after* our own
70
+ write-back, so the runtime's own writes never cause an infinite re-run loop —
71
+ only an external edit advances the mtime past the baseline.
72
+ """
73
+ out(f"▶ watching {path} (every {interval}s) — edit & save to re-run, Ctrl+C to stop")
74
+ last = None
75
+ runs = 0
76
+ try:
77
+ while True:
78
+ try:
79
+ mtime = os.path.getmtime(path)
80
+ except OSError:
81
+ time.sleep(interval)
82
+ continue
83
+ if mtime != last:
84
+ if last is not None:
85
+ out("\n↻ change detected")
86
+ run(path, workflow_name=workflow_name, write_back=write_back, out=out)
87
+ runs += 1
88
+ try: # baseline AFTER write-back so we don't self-trigger
89
+ last = os.path.getmtime(path)
90
+ except OSError:
91
+ last = mtime
92
+ if max_runs and runs >= max_runs:
93
+ out(f"\n· reached max-runs={max_runs}, stopping")
94
+ return
95
+ time.sleep(interval)
96
+ except KeyboardInterrupt:
97
+ out("\n· stopped")
98
+
99
+
100
+ def run_workflow(wf, memory, ctx, out) -> bool:
101
+ """Run one workflow's steps; returns True if every step succeeded."""
102
+ out(f"\n▶ workflow: {wf.name or '(unnamed)'}")
103
+ return run_steps(wf.steps, memory, ctx, out)
104
+
105
+
106
+ def run_steps(steps, memory, ctx, out) -> bool:
107
+ """Run a list of steps top-to-bottom; returns True if all succeeded."""
108
+ ok_all = True
109
+ for idx, step in enumerate(steps, 1):
110
+ if not _run_step(idx, step, memory, ctx, out):
111
+ ok_all = False
112
+ return ok_all
113
+
114
+
115
+ def _run_step(idx, step, memory, ctx, out) -> bool:
116
+ plugin = plugins.get(step.plugin)
117
+ label = f" step {idx} @{step.plugin}"
118
+ if plugin is None:
119
+ out(f"{label} ✗ unknown plugin")
120
+ return False
121
+ params = {k: substitute(v, memory) for k, v in step.params.items()}
122
+ result = plugin(params, ctx)
123
+ if result.ok:
124
+ out(f"{label} ✓")
125
+ _emit(result.output, out)
126
+ else:
127
+ out(f"{label} ✗ (exit {result.code})")
128
+ _emit(result.error or result.output, out)
129
+ return result.ok
130
+
131
+
132
+ def _emit(text, out) -> None:
133
+ if text and text.strip():
134
+ for ln in text.rstrip().splitlines():
135
+ out(f" {ln}")
136
+
137
+
138
+ def _apply_hooks(hooks, memory, out) -> None:
139
+ for h in hooks:
140
+ m = _SET_RE.match(h.strip())
141
+ if not m:
142
+ continue
143
+ key, raw = m.group(1), m.group(2)
144
+ if not key.startswith(RUNTIME_NAMESPACE):
145
+ out(
146
+ f" ⚠ refused: @on_done may only write runtime-owned memory "
147
+ f"('{RUNTIME_NAMESPACE}*'); '{key}' is agent-owned and was left "
148
+ f"untouched."
149
+ )
150
+ continue
151
+ memory[key] = parse_scalar(raw)
runxmd/memory.py ADDED
@@ -0,0 +1,22 @@
1
+ """Memory substitution — power #2 of three (SPEC §4).
2
+
3
+ Resolves ``{{ memory.<key> }}`` references inside step params before execution.
4
+ Read-in and write-back are handled by the parser + executor; this module is just
5
+ the templating layer.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from typing import Any
11
+
12
+ _REF = re.compile(r"\{\{\s*memory\.([a-zA-Z0-9_.]+)\s*\}\}")
13
+
14
+
15
+ def substitute(value: Any, memory: dict) -> Any:
16
+ """Replace ``{{ memory.key }}`` in a string. Non-strings pass through.
17
+
18
+ Missing keys resolve to empty string (SPEC §4).
19
+ """
20
+ if not isinstance(value, str):
21
+ return value
22
+ return _REF.sub(lambda m: str(memory.get(m.group(1), "")), value)