diff --git a/CHANGELOG.md b/CHANGELOG.md index 6002af2..e6fceac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to `simplicio-prompt` are documented here. Format roughly follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning is [SemVer](https://semver.org/). +## [1.12.0] — 2026-05-29 — SendSprint batch JSONL + +### Added +- `simplicio-prompt --batch tasks.jsonl --subagents N --json` batch execution + mode for SendSprint fan-out. It reuses one provider/runtime boot for many + task prompts, emits one NDJSON result per input line, preserves `task_id`, + honors existing runtime flags such as `--dry-run`, `--provider`, `--model`, + `--subagents`, `--no-cache` and `--diversify`, and keeps processing after + malformed lines or per-task errors. + ## [1.11.0] — 2026-05-29 — Phase 1 + integration + infra Roadmap **Phase 1 (Diverse-prompt fan-out)** kernel-side delivery plus the diff --git a/README.md b/README.md index 1b022d9..d10b97b 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,9 @@ Opt-in paths to BATCH: BATCH directive. - Run `npx simplicio-prompt --batch` / `--batch --raw` to print the BATCH template. +- Run `npx simplicio-prompt --batch tasks.jsonl --subagents 600 --json` to + process many SendSprint items with one runtime/provider boot and emit one + NDJSON result per task. - Import `getBatchPrompt()` / `getBatchPromptSection()` from the npm API, or use the `simplicio-prompt/batch-prompt` package export. - Have your coding agent invoke `simplicio-subagents` via shell when the @@ -174,6 +177,21 @@ python kernel/subagent_runtime.py --provider mimo --task "..." python kernel/subagent_runtime.py --provider local --subagents 50 --task "..." # Ollama ``` +For orchestrators that already have many task prompts, use JSONL batch mode. +Each input line is one object: + +```json +{"task_id":"WS-101","prompt":"Audit this change","system":"You are a reviewer"} +``` + +```bash +simplicio-prompt --batch tasks.jsonl --subagents 600 --dry-run --json > out.ndjson +``` + +Each output line is `{ "task_id": "...", "status": "ok", "result": ... }` or +`{ "task_id": "...", "status": "error", "error": "..." }`. A malformed line or +single task error does not stop the rest of the batch. + Or programmatically: ```python diff --git a/bin/simplicio-prompt.mjs b/bin/simplicio-prompt.mjs index 875ec3c..b2935bc 100755 --- a/bin/simplicio-prompt.mjs +++ b/bin/simplicio-prompt.mjs @@ -29,6 +29,7 @@ * npx simplicio-prompt --install AGENTS.md * npx simplicio-prompt --install .cursorrules */ +import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; import { @@ -56,6 +57,10 @@ function parseArgs(argv) { target: null, // legacy --install path raw: false, batch: false, // --batch selects the BATCH template (fan-out runtime) + batchFile: null, // --batch runs tasks through one kernel boot + json: false, + dryRun: false, + passthrough: [], pluginTargets: [], // --target , repeatable installAll: false, }; @@ -84,6 +89,36 @@ function parseArgs(argv) { args.raw = true; } else if (k === "--batch" || k === "--full-runtime") { args.batch = true; + const next = argv[i + 1]; + if (next && !next.startsWith("--")) { + args.mode = "batch-run"; + args.batchFile = next; + i++; + } + } else if (k === "--json") { + args.json = true; + } else if (k === "--dry-run") { + args.dryRun = true; + } else if ( + [ + "--subagents", + "--provider", + "--model", + "--lane", + "--max-tokens", + "--temperature", + "--overall-timeout-s", + "--show", + "--diversify-seed", + ].includes(k) + ) { + const next = argv[i + 1]; + if (next && !next.startsWith("--")) { + args.passthrough.push(k, next); + i++; + } + } else if (["--no-cache", "--diversify"].includes(k)) { + args.passthrough.push(k); } else if (k === "--path") { args.mode = "path"; } else if (k === "--help" || k === "-h") { @@ -134,6 +169,8 @@ Two templates ship: Print / inspect: simplicio-prompt Print ONE-SHOT prompt to stdout simplicio-prompt --batch Print BATCH prompt to stdout + simplicio-prompt --batch tasks.jsonl --subagents 600 --json + Run JSONL batch and emit NDJSON simplicio-prompt --raw Print only the Prompt section simplicio-prompt --batch --raw Print only the BATCH Prompt body simplicio-prompt --path Print absolute path of the prompt file @@ -158,6 +195,7 @@ Examples: npx simplicio-prompt --target claude-code npx simplicio-prompt --target cursor --target copilot npx simplicio-prompt --install-all + npx simplicio-prompt --batch tasks.jsonl --subagents 600 --dry-run --json npx simplicio-prompt --raw > my-prompt.md `); } @@ -256,6 +294,35 @@ function printTargetList() { } } +function runBatch(args) { + if (!args.batchFile) { + throw new Error("batch mode requires a JSONL file after --batch"); + } + const python = process.env.PYTHON || process.env.PYTHON3 || "python3"; + const runner = resolve(PKG_ROOT, "kernel", "subagent_runtime.py"); + const pyArgs = [ + runner, + "--batch", + resolve(process.cwd(), args.batchFile), + ...args.passthrough, + ]; + if (args.dryRun) pyArgs.push("--dry-run"); + if (args.json) pyArgs.push("--json"); + + const result = spawnSync(python, pyArgs, { + cwd: PKG_ROOT, + encoding: "utf-8", + }); + if (result.error) { + throw result.error; + } + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + function main() { const args = parseArgs(process.argv.slice(2)); @@ -263,6 +330,7 @@ function main() { if (args.mode === "version") return console.log(loadVersion()); if (args.mode === "path") return console.log(selectPromptPath(args)); if (args.mode === "list-targets") return printTargetList(); + if (args.mode === "batch-run") return runBatch(args); const promptPath = selectPromptPath(args); const { full, section: body } = loadPromptAsset(promptPath); diff --git a/kernel/README.md b/kernel/README.md index feb0cbf..df75843 100644 --- a/kernel/README.md +++ b/kernel/README.md @@ -64,6 +64,18 @@ python kernel/subagent_runtime.py --provider deepseek --subagents 600 \ --task "..." --dry-run # opt-in to max-breadth ``` +Batch JSONL mode reuses one provider/runtime boot for many tasks and emits one +line per result when `--json` is set: + +```bash +python kernel/subagent_runtime.py --batch tasks.jsonl --subagents 600 \ + --dry-run --json > out.ndjson +``` + +Each input line is `{ "task_id": "...", "prompt": "...", "system": "..." }`. +Malformed lines and per-task failures emit `status: "error"` and do not stop the +remaining tasks. + ## Safe speed model The reference kernel increases throughput without provider-ban risk by avoiding diff --git a/kernel/__init__.py b/kernel/__init__.py index 5a83f57..93f3468 100644 --- a/kernel/__init__.py +++ b/kernel/__init__.py @@ -7,4 +7,4 @@ ``batch_spawn`` model into real subagents. """ -__version__ = "1.11.0" +__version__ = "1.12.0" diff --git a/kernel/subagent_runtime.py b/kernel/subagent_runtime.py index 7839dcc..7e1e1ff 100644 --- a/kernel/subagent_runtime.py +++ b/kernel/subagent_runtime.py @@ -20,6 +20,10 @@ # offline cost projection / demo, no API key, no network: python kernel/subagent_runtime.py --provider deepseek --subagents 600 \\ --task "..." --dry-run + + # batch JSONL mode, one runtime/provider boot for many SendSprint items: + python kernel/subagent_runtime.py --batch tasks.jsonl --subagents 600 \\ + --dry-run --json """ from __future__ import annotations @@ -92,6 +96,18 @@ def default_prompt_builder(task: str, index: int, total: int) -> Dict[str, Any]: } +def batch_prompt_builder(system: str | None = None) -> PromptBuilder: + """Build per-subagent prompts for one batch item, preserving its system text.""" + + def build(task: str, index: int, total: int) -> Dict[str, Any]: + payload = default_prompt_builder(task, index, total) + if system: + payload["system"] = f"{system}\n\n{payload['system']}" + return payload + + return build + + @dataclass class SubagentResult: agent_id: int @@ -344,7 +360,12 @@ def _parse_args(argv: List[str]) -> argparse.Namespace: ) parser.add_argument("--provider", default=None, help="deepseek | mimo | local | ") parser.add_argument("--model", default=None, help="override the provider model") - parser.add_argument("--task", required=True, help="the task to fan out") + parser.add_argument("--task", default=None, help="the task to fan out") + parser.add_argument( + "--batch", + default=None, + help="JSONL file with one {task_id,prompt,system?} object per line", + ) parser.add_argument( "--subagents", type=int, @@ -395,7 +416,102 @@ def _parse_args(argv: List[str]) -> argparse.Namespace: parser.add_argument( "--show", type=int, default=3, help="how many sample outputs to print (text mode)" ) - return parser.parse_args(argv) + args = parser.parse_args(argv) + if not args.batch and not args.task: + parser.error("--task is required unless --batch is provided") + return args + + +def _batch_error(task_id: str, message: str, *, line: int | None = None) -> Dict[str, Any]: + payload: Dict[str, Any] = { + "task_id": task_id, + "status": "error", + "error": message, + } + if line is not None: + payload["line"] = line + return payload + + +def _iter_batch_tasks(path: str): + with open(path, "r", encoding="utf-8") as handle: + for line_no, raw in enumerate(handle, start=1): + text = raw.strip() + if not text: + continue + try: + payload = json.loads(text) + except json.JSONDecodeError as error: + yield None, _batch_error( + f"line:{line_no}", + f"malformed JSON: {error.msg}", + line=line_no, + ) + continue + if not isinstance(payload, dict): + yield None, _batch_error( + f"line:{line_no}", + "batch line must be a JSON object", + line=line_no, + ) + continue + task_id = str(payload.get("task_id") or f"line:{line_no}") + prompt = payload.get("prompt") + if not isinstance(prompt, str) or not prompt.strip(): + yield None, _batch_error(task_id, "prompt is required", line=line_no) + continue + system = payload.get("system") + if system is not None and not isinstance(system, str): + yield None, _batch_error(task_id, "system must be a string", line=line_no) + continue + yield { + "task_id": task_id, + "prompt": prompt, + "system": system, + "line": line_no, + }, None + + +def _run_batch(args: argparse.Namespace, runtime: SubagentRuntime) -> int: + assert args.batch is not None + for task, error in _iter_batch_tasks(args.batch): + if error is not None: + if args.json: + print(json.dumps(error, ensure_ascii=False)) + else: + print(f"{error['task_id']}: ERROR {error['error']}") + continue + assert task is not None + try: + report = runtime.run( + task["prompt"], + subagents=args.subagents, + lane=args.lane, + prompt_builder=batch_prompt_builder(task.get("system")), + use_cache=not args.no_cache, + diversify=args.diversify, + diversify_seed=args.diversify_seed, + overall_timeout_s=args.overall_timeout_s, + ) + payload = { + "task_id": task["task_id"], + "status": "ok" if report.status == "ok" else report.status, + "result": report.to_dict(), + } + except Exception as error: # noqa: BLE001 - keep batch items isolated + payload = _batch_error(task["task_id"], str(error), line=task["line"]) + if args.json: + print(json.dumps(payload, ensure_ascii=False)) + else: + if payload["status"] == "ok": + result = payload["result"] + print( + f"{payload['task_id']}: ok " + f"{result['completed']}/{result['requested']} subagents" + ) + else: + print(f"{payload['task_id']}: ERROR {payload['error']}") + return 0 def main(argv: Optional[List[str]] = None) -> int: @@ -409,8 +525,11 @@ def main(argv: Optional[List[str]] = None) -> int: runtime = SubagentRuntime( provider, max_tokens=args.max_tokens, temperature=args.temperature ) + if args.batch: + return _run_batch(args, runtime) + report = runtime.run( - args.task, + args.task or "", subagents=args.subagents, lane=args.lane, use_cache=not args.no_cache, diff --git a/package.json b/package.json index 4749bda..bdfa6e7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "simplicio-prompt", - "version": "1.11.0", + "version": "1.12.0", "description": "Multi-IDE plugin: Tuple-Space + Yool safe-speed runtime prompt for Claude Code, Codex, Hermes, OpenCode/OpenClaw, Cursor, GitHub Copilot, Cline, Aider, Gemini CLI. One installer drops the runtime contract into the right rule file for each agent; the Claude Code plugin ships a UserPromptSubmit hook for always-on invocation. Includes a dependency-free Python runtime for real subagents on DeepSeek / MiMo / OpenRouter / local LLMs plus PromptFanout adapters and token/cost observability.", "type": "module", "main": "./index.mjs", diff --git a/pyproject.toml b/pyproject.toml index 28167b8..3d0ca51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "simplicio-prompt" -version = "1.11.0" +version = "1.12.0" description = "Tuple-Space + Yool safe-speed runtime kernel: lazy 1,000,000+ subagent batch_spawn, adaptive lane concurrency, and a dependency-free OpenAI-compatible provider client for real subagents on DeepSeek / MiMo / OpenRouter / local LLMs." readme = "README.md" requires-python = ">=3.8" diff --git a/tests/cli.test.mjs b/tests/cli.test.mjs index 5b31d10..31282e3 100644 --- a/tests/cli.test.mjs +++ b/tests/cli.test.mjs @@ -202,6 +202,71 @@ test("--batch --raw prints the BATCH prompt body (opt-in)", () => { ); }); +test("--batch emits one NDJSON result per task", () => { + withTempDir((dir) => { + const tasks = resolve(dir, "tasks.jsonl"); + writeFileSync(tasks, [ + JSON.stringify({ task_id: "WS-101", prompt: "first task", system: "planner" }), + JSON.stringify({ task_id: "WS-102", prompt: "second task" }), + "", + ].join("\n")); + + const res = runCli([ + "--batch", + tasks, + "--subagents", + "2", + "--dry-run", + "--json", + ], PKG_ROOT); + + assertEq(res.status, 0, "exit code"); + assertEq(res.stderr, "", "stderr"); + const lines = res.stdout.trim().split("\n").map((line) => JSON.parse(line)); + assertEq(lines.length, 2, "line count"); + assertEq(lines[0].task_id, "WS-101", "first task id"); + assertEq(lines[0].status, "ok", "first status"); + assertEq(lines[0].result.requested, 2, "first subagent count"); + assertEq(lines[1].task_id, "WS-102", "second task id"); + assertEq(lines[1].status, "ok", "second status"); + }); +}); + +test("--batch keeps going after task and malformed-line errors", () => { + withTempDir((dir) => { + const tasks = resolve(dir, "tasks.jsonl"); + writeFileSync(tasks, [ + JSON.stringify({ task_id: "WS-201", prompt: "valid task" }), + JSON.stringify({ task_id: "WS-202" }), + "{not valid json", + JSON.stringify({ task_id: "WS-203", prompt: "valid after errors" }), + "", + ].join("\n")); + + const res = runCli([ + "--batch", + tasks, + "--subagents", + "1", + "--dry-run", + "--json", + ], PKG_ROOT); + + assertEq(res.status, 0, "exit code"); + const lines = res.stdout.trim().split("\n").map((line) => JSON.parse(line)); + assertEq(lines.length, 4, "line count"); + assertEq(lines[0].status, "ok", "first task status"); + assertEq(lines[1].task_id, "WS-202", "missing-prompt task id"); + assertEq(lines[1].status, "error", "missing prompt status"); + assertTrue(lines[1].error.includes("prompt"), "missing prompt error"); + assertEq(lines[2].task_id, "line:3", "malformed line id"); + assertEq(lines[2].status, "error", "malformed line status"); + assertTrue(lines[2].error.includes("malformed JSON"), "malformed line error"); + assertEq(lines[3].task_id, "WS-203", "last task id"); + assertEq(lines[3].status, "ok", "last task status"); + }); +}); + test("--raw preserves simplicio-cli contract precedence", () => { const res = runCli(["--raw"], PKG_ROOT); assertEq(res.status, 0, "exit code");