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 +7 -0
- runxmd/agent.py +185 -0
- runxmd/cli.py +102 -0
- runxmd/executor.py +151 -0
- runxmd/memory.py +22 -0
- runxmd/parser.py +252 -0
- runxmd/plugins/__init__.py +7 -0
- runxmd/plugins/base.py +30 -0
- runxmd/plugins/fs.py +45 -0
- runxmd/plugins/http.py +51 -0
- runxmd/plugins/lang.py +60 -0
- runxmd/plugins/llm.py +78 -0
- runxmd/plugins/shell.py +26 -0
- runxmd-1.0.0.dist-info/METADATA +340 -0
- runxmd-1.0.0.dist-info/RECORD +19 -0
- runxmd-1.0.0.dist-info/WHEEL +5 -0
- runxmd-1.0.0.dist-info/entry_points.txt +2 -0
- runxmd-1.0.0.dist-info/licenses/LICENSE +21 -0
- runxmd-1.0.0.dist-info/top_level.txt +1 -0
runxmd/__init__.py
ADDED
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)
|