skillflow-py 1.4.2__tar.gz → 1.4.4__tar.gz
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.
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/PKG-INFO +1 -1
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/pyproject.toml +1 -1
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/core.py +42 -23
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/graph.py +9 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/write_tools.py +223 -27
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow_py.egg-info/PKG-INFO +1 -1
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_core.py +51 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_recovery.py +67 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_write_tools.py +172 -2
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/LICENSE +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/README.md +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/setup.cfg +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/__init__.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/agent_registry.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/context.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/convert_cli.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/exceptions.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/notifications.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/outbox.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/plugins/linter/__init__.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/plugins/linter/cli.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/plugins/linter/tools/skillflow_lint/impl.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/plugins/linter/tools/skillflow_lint/tool.yaml +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/plugins/skill_converter/AGENT.md +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/plugins/skill_converter/__init__.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/plugins/skill_converter/converter.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/plugins/skill_converter/prompts/analyze_skill.md +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/plugins/skill_converter/prompts/design_graph.md +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/plugins/skill_converter/prompts/explain_design.md +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/plugins/skill_converter/prompts/fix_issues.md +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/plugins/skill_converter/skill_converter.yaml +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/plugins/skill_runner/AGENT.md +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/plugins/skill_runner/__init__.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/plugins/skill_runner/runner.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/read_tools.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/recovery.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/run_cli.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/schema.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/step_validation.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tool_loader.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/__init__.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/dir_tree/impl.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/dir_tree/tool.yaml +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/draft_commit/impl.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/draft_commit/tool.yaml +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/file_exists/impl.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/file_exists/tool.yaml +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/git_sync_pre/impl.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/git_sync_pre/tool.yaml +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/json_schema/impl.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/json_schema/tool.yaml +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/lint/impl.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/lint/tool.yaml +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/list_tree/impl.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/list_tree/tool.yaml +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/notify/impl.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/notify/tool.yaml +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/pytest/impl.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/pytest/tool.yaml +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/read_file/impl.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/read_file/tool.yaml +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/repo_apply/impl.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/repo_apply/tool.yaml +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/repo_validate/impl.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/repo_validate/tool.yaml +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/write/impl.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/tools/write/tool.yaml +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/validation.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/workspace.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow_py.egg-info/SOURCES.txt +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow_py.egg-info/dependency_links.txt +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow_py.egg-info/entry_points.txt +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow_py.egg-info/requires.txt +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow_py.egg-info/top_level.txt +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_context.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_exceptions.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_existing_repo_apply.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_git_sync_pre.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_graph.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_integration.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_integration_configs.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_migration_regression.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_noop_floor_fix.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_notifications.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_outbox.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_read_tools_exec.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_read_tools_label.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_regression.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_step_validation.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_tool_loader.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_tool_lock_contention.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_tools.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_validation.py +0 -0
- {skillflow_py-1.4.2 → skillflow_py-1.4.4}/tests/test_workspace.py +0 -0
|
@@ -1286,29 +1286,44 @@ class SkillFlow:
|
|
|
1286
1286
|
hook_name: str, items: list) -> dict:
|
|
1287
1287
|
"""Run a list of tool hooks in order (e.g. repo_apply then repo_delete).
|
|
1288
1288
|
|
|
1289
|
-
Each item is a ``{'tool', 'params', 'on_failure'?}`` dict
|
|
1290
|
-
:meth:`_execute_tool_hook` (full variable resolution +
|
|
1291
|
-
injection). Per-item
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1289
|
+
Each item is a ``{'tool', 'params', 'on_failure'?, 'max_retries'?}`` dict
|
|
1290
|
+
executed via :meth:`_execute_tool_hook` (full variable resolution +
|
|
1291
|
+
project_root injection). Per-item policy is honored IN PLACE:
|
|
1292
|
+
``on_failure: retry`` re-runs THAT item up to its own ``max_retries``
|
|
1293
|
+
(without re-running earlier, already-succeeded items or the agent step);
|
|
1294
|
+
``warn``/``skip`` log and continue to the next item; ``fail`` (or a retry
|
|
1295
|
+
item that exhausts its retries) stops the sequence and fails the step.
|
|
1296
|
+
The sequence never bubbles ``retry`` to the caller — that would reset the
|
|
1297
|
+
whole agent step and re-execute earlier, already-committed items.
|
|
1295
1298
|
"""
|
|
1296
1299
|
detail_files = None
|
|
1297
1300
|
for item in items:
|
|
1298
1301
|
if not (isinstance(item, dict) and "tool" in item):
|
|
1299
1302
|
return {"passed": False,
|
|
1300
1303
|
"error": f"Invalid tool hook in '{hook_name}': {item!r}"}
|
|
1301
|
-
|
|
1304
|
+
on_failure = item.get("on_failure", "fail")
|
|
1305
|
+
max_retries = int(item.get("max_retries", 0) or 0)
|
|
1306
|
+
res = {"passed": False}
|
|
1307
|
+
for attempt in range(max_retries + 1):
|
|
1308
|
+
res = self._execute_tool_hook(token, node, hook_name, item)
|
|
1309
|
+
if res.get("passed", True):
|
|
1310
|
+
break
|
|
1311
|
+
if on_failure == "retry" and attempt < max_retries:
|
|
1312
|
+
# Retry THIS item in place — do not re-run earlier items.
|
|
1313
|
+
self._emit_lifecycle_event(
|
|
1314
|
+
token, hook_name, "retry", res.get("error", ""))
|
|
1315
|
+
continue
|
|
1316
|
+
break
|
|
1302
1317
|
if not res.get("passed", True):
|
|
1303
|
-
on_failure = item.get("on_failure", "fail")
|
|
1304
1318
|
if on_failure in ("warn", "skip"):
|
|
1305
1319
|
self._emit_lifecycle_event(
|
|
1306
1320
|
token, hook_name,
|
|
1307
1321
|
"warned" if on_failure == "warn" else "skipped",
|
|
1308
1322
|
res.get("error", ""))
|
|
1309
1323
|
continue
|
|
1310
|
-
#
|
|
1311
|
-
|
|
1324
|
+
# 'fail', or a 'retry' item that exhausted max_retries → fail the
|
|
1325
|
+
# step (never bubble 'retry', which re-runs the whole sequence).
|
|
1326
|
+
return {**res, "on_failure": "fail"}
|
|
1312
1327
|
files = res.get("files")
|
|
1313
1328
|
if isinstance(files, list):
|
|
1314
1329
|
detail_files = files
|
|
@@ -2326,10 +2341,12 @@ class SkillFlow:
|
|
|
2326
2341
|
so it retries promptly instead of stalling until its claim times out.
|
|
2327
2342
|
|
|
2328
2343
|
Capped at 3 crashes: a tool that crashes deterministically is broken,
|
|
2329
|
-
not transient
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2344
|
+
not transient. On the 3rd crash the RUN is failed (not just the step) —
|
|
2345
|
+
marking only the step failed + current_node=NULL would let advance_run
|
|
2346
|
+
re-resolve back to the tool and open a FRESH instance with a reset
|
|
2347
|
+
counter, so the crash loop never actually stopped (only the host's
|
|
2348
|
+
step-count valve caught it). The SF-20 stale-recovery cap does NOT cover
|
|
2349
|
+
this path (a crash reopens directly, never via recover_stale_claims).
|
|
2333
2350
|
"""
|
|
2334
2351
|
with self._tx() as conn:
|
|
2335
2352
|
row = conn.execute(
|
|
@@ -2353,11 +2370,9 @@ class SkillFlow:
|
|
|
2353
2370
|
"claimed_by = NULL, updated_at = datetime('now') WHERE id = ?",
|
|
2354
2371
|
(error_msg, row["id"]),
|
|
2355
2372
|
)
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
(run_id,),
|
|
2360
|
-
)
|
|
2373
|
+
# Fail the RUN so advance_run returns None instead of re-resolving
|
|
2374
|
+
# the predecessor's transition and opening a fresh tool instance.
|
|
2375
|
+
self._fail_run_in_tx(conn, run_id, error_msg)
|
|
2361
2376
|
self.notifications.publish_sync(
|
|
2362
2377
|
"step_failed",
|
|
2363
2378
|
{"run_id": run_id, "step_id": step_id,
|
|
@@ -2984,10 +2999,14 @@ class SkillFlow:
|
|
|
2984
2999
|
try:
|
|
2985
3000
|
node = self._get_resolver_for_run(
|
|
2986
3001
|
row["run_id"]).get_node(row["step_id"])
|
|
2987
|
-
if
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
3002
|
+
if node and node.step_type == "tool":
|
|
3003
|
+
if node.timeout_seconds == 0:
|
|
3004
|
+
# 0 = "no timeout": a live tool may run arbitrarily
|
|
3005
|
+
# long, so it is NEVER stale — reclaiming it would
|
|
3006
|
+
# relaunch it concurrently with itself (the rampage).
|
|
3007
|
+
continue
|
|
3008
|
+
if node.timeout_seconds > window:
|
|
3009
|
+
window = float(node.timeout_seconds)
|
|
2991
3010
|
except Exception:
|
|
2992
3011
|
pass
|
|
2993
3012
|
threshold = time.strftime(
|
|
@@ -850,6 +850,15 @@ def _flags_match(match: dict, flags: dict, *,
|
|
|
850
850
|
return False
|
|
851
851
|
data = _parse_json_extract_last(content)
|
|
852
852
|
if data is None:
|
|
853
|
+
# The file exists but no JSON value could be parsed out of it
|
|
854
|
+
# (e.g. an invalid \escape written by an agent). Without this
|
|
855
|
+
# log the run dies on "No matching transition ... flags {}"
|
|
856
|
+
# with no hint that the real problem is a syntax error.
|
|
857
|
+
import logging
|
|
858
|
+
logging.getLogger("skillflow.graph").warning(
|
|
859
|
+
"from_file match: '%s' exists but contains no parseable "
|
|
860
|
+
"JSON — transition treated as unmatched",
|
|
861
|
+
match["from_file"])
|
|
853
862
|
return False
|
|
854
863
|
return data.get(match["field"]) == match["value"]
|
|
855
864
|
|
|
@@ -25,6 +25,7 @@ from __future__ import annotations
|
|
|
25
25
|
|
|
26
26
|
import json
|
|
27
27
|
import os
|
|
28
|
+
import re
|
|
28
29
|
from pathlib import Path
|
|
29
30
|
|
|
30
31
|
|
|
@@ -38,6 +39,95 @@ def _ensure_str(value, default: str = "") -> str:
|
|
|
38
39
|
return json.dumps(value, ensure_ascii=False)
|
|
39
40
|
|
|
40
41
|
|
|
42
|
+
# ── Structured JSON slots ────────────────────────────────────────────
|
|
43
|
+
# A model asked to author a JSON document as a STRING inside tool-call JSON
|
|
44
|
+
# must escape every backslash twice; under-escaping by one level (e.g. a
|
|
45
|
+
# regex `/\.0$/` quoted in review feedback) produces a file that json.loads
|
|
46
|
+
# rejects — and the run dies on an unmatched transition. For .json slots we
|
|
47
|
+
# therefore move the document INTO the tool arguments, where the provider's
|
|
48
|
+
# constrained decoding guarantees validity:
|
|
49
|
+
# tier 1 — the slot's `format` spec parses → one argument per field;
|
|
50
|
+
# tier 2 — no parseable format → a single `content` argument typed OBJECT;
|
|
51
|
+
# tier 3 — a string is still accepted but json.loads-validated at write
|
|
52
|
+
# time, returning an actionable tool error instead of writing a
|
|
53
|
+
# file no reader can parse.
|
|
54
|
+
|
|
55
|
+
_TYPE_TOKENS = {
|
|
56
|
+
"bool": {"type": "boolean"},
|
|
57
|
+
"str": {"type": "string"},
|
|
58
|
+
"int": {"type": "integer"},
|
|
59
|
+
"float": {"type": "number"},
|
|
60
|
+
"num": {"type": "number"},
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _is_json_file(pattern: str) -> bool:
|
|
65
|
+
return pattern.lower().endswith(".json")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _format_value_to_spec(value) -> dict | None:
|
|
69
|
+
"""Convert one parsed format value into a parameter spec, or None."""
|
|
70
|
+
if isinstance(value, str):
|
|
71
|
+
token = value[2:-2] if value.startswith("__") and value.endswith("__") else None
|
|
72
|
+
if token in _TYPE_TOKENS:
|
|
73
|
+
return dict(_TYPE_TOKENS[token])
|
|
74
|
+
# Free-text value = a string field described by that text
|
|
75
|
+
# (e.g. "description": "ONE-LINE summary (max 80 chars)").
|
|
76
|
+
return {"type": "string", "description": value}
|
|
77
|
+
if isinstance(value, list):
|
|
78
|
+
if len(value) != 1:
|
|
79
|
+
return None
|
|
80
|
+
items = _format_value_to_spec(value[0])
|
|
81
|
+
if items is None:
|
|
82
|
+
return None
|
|
83
|
+
return {"type": "array", "items": items}
|
|
84
|
+
if isinstance(value, dict):
|
|
85
|
+
props = {}
|
|
86
|
+
for k, v in value.items():
|
|
87
|
+
spec = _format_value_to_spec(v)
|
|
88
|
+
if spec is None:
|
|
89
|
+
return None
|
|
90
|
+
props[k] = spec
|
|
91
|
+
return {"type": "object", "properties": props}
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _parse_format_spec(format_spec) -> dict | None:
|
|
96
|
+
"""Parse a pseudo-JSON ``format`` spec into per-field parameter specs.
|
|
97
|
+
|
|
98
|
+
``'{"passed": bool, "feedback": str, "suggestions": [str, ...]}'``
|
|
99
|
+
→ ``{"passed": {"type": "boolean"}, "feedback": {"type": "string"},
|
|
100
|
+
"suggestions": {"type": "array", "items": {"type": "string"}}}``
|
|
101
|
+
|
|
102
|
+
Returns None when the spec is absent or uses constructs the mini-DSL
|
|
103
|
+
can't resolve (e.g. a named shape like ``[subtask, ...]``) — callers
|
|
104
|
+
then fall back to a whole-document object parameter.
|
|
105
|
+
"""
|
|
106
|
+
if not format_spec or not isinstance(format_spec, str):
|
|
107
|
+
return None
|
|
108
|
+
spec = format_spec.strip()
|
|
109
|
+
if not spec.startswith("{"):
|
|
110
|
+
return None
|
|
111
|
+
# ", ..." ellipses are illustrative — drop them
|
|
112
|
+
normalized = re.sub(r",\s*\.\.\.", "", spec)
|
|
113
|
+
# quote bare type tokens so the spec becomes valid JSON
|
|
114
|
+
normalized = re.sub(
|
|
115
|
+
r"\b(bool|str|int|float|num)\b", r'"__\1__"', normalized)
|
|
116
|
+
try:
|
|
117
|
+
data = json.loads(normalized)
|
|
118
|
+
except (json.JSONDecodeError, ValueError):
|
|
119
|
+
return None
|
|
120
|
+
if not isinstance(data, dict) or not data:
|
|
121
|
+
return None
|
|
122
|
+
props = {}
|
|
123
|
+
for key, value in data.items():
|
|
124
|
+
spec_v = _format_value_to_spec(value)
|
|
125
|
+
if spec_v is None:
|
|
126
|
+
return None
|
|
127
|
+
props[key] = spec_v
|
|
128
|
+
return props
|
|
129
|
+
|
|
130
|
+
|
|
41
131
|
def _normalize_fixed_entry(value) -> dict:
|
|
42
132
|
"""Normalize a fixed output entry to {file, on_exists, format} dict."""
|
|
43
133
|
if isinstance(value, str):
|
|
@@ -165,30 +255,71 @@ def generate_write_tool_schemas(output_mode: str,
|
|
|
165
255
|
fmt_hint = f"\nExpected format: {format_spec.strip()}" if format_spec else ""
|
|
166
256
|
is_glob = "*" in pattern
|
|
167
257
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
#
|
|
173
|
-
#
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
"id"
|
|
178
|
-
|
|
179
|
-
|
|
258
|
+
field_props = (_parse_format_spec(format_spec)
|
|
259
|
+
if _is_json_file(pattern) else None)
|
|
260
|
+
|
|
261
|
+
if field_props:
|
|
262
|
+
# Tier 1 — one argument per document field: the model authors
|
|
263
|
+
# no JSON-in-a-string, so backslashes in field text can never
|
|
264
|
+
# produce an unparseable file.
|
|
265
|
+
params = {}
|
|
266
|
+
if is_glob and "id" not in field_props:
|
|
267
|
+
params["id"] = {"type": "string", "required": True,
|
|
268
|
+
"description": f"Replaces * in {pattern}"}
|
|
269
|
+
for fname, fspec in field_props.items():
|
|
270
|
+
p = dict(fspec)
|
|
271
|
+
p["required"] = True
|
|
272
|
+
if is_glob and fname == "id":
|
|
273
|
+
extra = f"Replaces * in {pattern}; also written as the 'id' field."
|
|
274
|
+
p["description"] = (
|
|
275
|
+
f"{p.get('description', '')} {extra}".strip())
|
|
276
|
+
params[fname] = p
|
|
277
|
+
create_params = dict(params)
|
|
278
|
+
write_hint = (fmt_hint + "\nProvide one argument per field — "
|
|
279
|
+
"do NOT pass the document as a JSON-encoded string.")
|
|
280
|
+
elif _is_json_file(pattern):
|
|
281
|
+
# Tier 2 — whole document as a structured OBJECT argument
|
|
282
|
+
# (format absent or beyond the mini-DSL).
|
|
283
|
+
content_spec = {
|
|
284
|
+
"type": "object", "required": True,
|
|
285
|
+
"description": ("The complete JSON document as a structured "
|
|
286
|
+
"object — NOT a JSON-encoded string."),
|
|
180
287
|
}
|
|
288
|
+
if is_glob:
|
|
289
|
+
id_desc = f"Replaces * in {pattern}"
|
|
290
|
+
if format_spec and '"id"' in format_spec:
|
|
291
|
+
id_desc += " — must equal the 'id' field value in your document"
|
|
292
|
+
params = {
|
|
293
|
+
"id": {"type": "string", "required": True,
|
|
294
|
+
"description": id_desc},
|
|
295
|
+
"content": content_spec,
|
|
296
|
+
}
|
|
297
|
+
else:
|
|
298
|
+
params = {"content": content_spec}
|
|
299
|
+
create_params = dict(params)
|
|
300
|
+
create_params["initialContent"] = create_params.pop("content")
|
|
301
|
+
write_hint = fmt_hint
|
|
181
302
|
else:
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
303
|
+
# Text slots (md etc.) — plain string content, unchanged.
|
|
304
|
+
if is_glob:
|
|
305
|
+
id_desc = f"Replaces * in {pattern}"
|
|
306
|
+
if format_spec and '"id"' in format_spec:
|
|
307
|
+
id_desc += " — must equal the 'id' field value in your file content"
|
|
308
|
+
params = {
|
|
309
|
+
"id": {"type": "string", "required": True,
|
|
310
|
+
"description": id_desc},
|
|
311
|
+
"content": {"type": "string", "required": True},
|
|
312
|
+
}
|
|
313
|
+
else:
|
|
314
|
+
params = {"content": {"type": "string", "required": True}}
|
|
315
|
+
create_params = dict(params)
|
|
186
316
|
create_params["initialContent"] = create_params.pop("content")
|
|
317
|
+
write_hint = fmt_hint
|
|
187
318
|
|
|
188
319
|
# write_{slot} — full replace
|
|
189
320
|
tools.append({
|
|
190
321
|
"name": f"write_{slot}",
|
|
191
|
-
"description": f"Replace {pattern} with new content.{
|
|
322
|
+
"description": f"Replace {pattern} with new content.{write_hint}",
|
|
192
323
|
"parameters": params,
|
|
193
324
|
})
|
|
194
325
|
|
|
@@ -198,7 +329,7 @@ def generate_write_tool_schemas(output_mode: str,
|
|
|
198
329
|
"description": (
|
|
199
330
|
f"Create {pattern} with initial content. "
|
|
200
331
|
f"If file already exists, it is archived with a numeric suffix, "
|
|
201
|
-
f"so {pattern} always holds the latest version.{
|
|
332
|
+
f"so {pattern} always holds the latest version.{write_hint}"
|
|
202
333
|
),
|
|
203
334
|
"parameters": create_params,
|
|
204
335
|
})
|
|
@@ -241,6 +372,62 @@ def generate_write_tool_schemas(output_mode: str,
|
|
|
241
372
|
return []
|
|
242
373
|
|
|
243
374
|
|
|
375
|
+
def _resolve_slot_document(slot: str, fixed: dict, params: dict) -> "tuple[str | None, dict | None]":
|
|
376
|
+
"""Resolve the text to write for a write_/create_{slot} call.
|
|
377
|
+
|
|
378
|
+
Returns ``(text, None)`` on success or ``(None, error_dict)`` — the error
|
|
379
|
+
goes back to the agent as the tool result, so it can self-correct within
|
|
380
|
+
the same step instead of a downstream reader choking on a bad file.
|
|
381
|
+
|
|
382
|
+
JSON slots accept three input shapes: per-field arguments (tier 1),
|
|
383
|
+
a structured object in content/initialContent (tier 2), or a raw string
|
|
384
|
+
that must survive ``json.loads`` (tier 3 — the legacy path that used to
|
|
385
|
+
write invalid escapes straight to disk). Non-JSON slots keep the legacy
|
|
386
|
+
string behaviour verbatim.
|
|
387
|
+
"""
|
|
388
|
+
entry = fixed.get(slot)
|
|
389
|
+
normalized = _normalize_fixed_entry(entry) if entry is not None else {"file": ""}
|
|
390
|
+
pattern = normalized.get("file", "")
|
|
391
|
+
|
|
392
|
+
raw = params.get("content")
|
|
393
|
+
if raw is None or raw == "":
|
|
394
|
+
raw = params.get("initialContent")
|
|
395
|
+
|
|
396
|
+
if not _is_json_file(pattern):
|
|
397
|
+
return _ensure_str(raw, ""), None
|
|
398
|
+
|
|
399
|
+
field_props = _parse_format_spec(normalized.get("format"))
|
|
400
|
+
if field_props and raw is None:
|
|
401
|
+
doc, missing = {}, []
|
|
402
|
+
for fname in field_props:
|
|
403
|
+
if fname in params:
|
|
404
|
+
doc[fname] = params[fname]
|
|
405
|
+
else:
|
|
406
|
+
missing.append(fname)
|
|
407
|
+
if missing:
|
|
408
|
+
return None, {"error": (
|
|
409
|
+
f"{slot}: missing required field argument(s): "
|
|
410
|
+
f"{', '.join(missing)} — provide one argument per field "
|
|
411
|
+
f"of the document.")}
|
|
412
|
+
return json.dumps(doc, indent=2, ensure_ascii=False), None
|
|
413
|
+
|
|
414
|
+
if isinstance(raw, (dict, list)):
|
|
415
|
+
return json.dumps(raw, indent=2, ensure_ascii=False), None
|
|
416
|
+
|
|
417
|
+
if isinstance(raw, str) and raw.strip():
|
|
418
|
+
try:
|
|
419
|
+
json.loads(raw)
|
|
420
|
+
except json.JSONDecodeError as e:
|
|
421
|
+
return None, {"error": (
|
|
422
|
+
f"content for '{pattern}' is not valid JSON: {e}. Pass the "
|
|
423
|
+
f"document as structured arguments (or a plain object), not a "
|
|
424
|
+
f"JSON-encoded string; if you must pass a string, double every "
|
|
425
|
+
f"backslash (\\\\).")}
|
|
426
|
+
return raw, None
|
|
427
|
+
|
|
428
|
+
return None, {"error": f"{slot}: no content provided"}
|
|
429
|
+
|
|
430
|
+
|
|
244
431
|
def resolve_write_target(slot: str, fixed: dict, params: dict) -> str:
|
|
245
432
|
"""Resolve the actual filename to write for a given write_* call."""
|
|
246
433
|
entry = fixed.get(slot)
|
|
@@ -260,23 +447,27 @@ def execute_write(slot: str, fixed: dict, params: dict,
|
|
|
260
447
|
directory = Path(output_dir)
|
|
261
448
|
directory.mkdir(parents=True, exist_ok=True)
|
|
262
449
|
|
|
263
|
-
if mode == "
|
|
264
|
-
# Flipped rename: archive old file, write new content to canonical name
|
|
265
|
-
archived = _archive_old_file(directory, base_name)
|
|
266
|
-
filename = base_name
|
|
267
|
-
elif mode == "append":
|
|
450
|
+
if mode == "append":
|
|
268
451
|
filename = base_name
|
|
269
452
|
path = directory / filename
|
|
270
453
|
content = _ensure_str(params.get("content", ""))
|
|
271
454
|
existing = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
272
455
|
path.write_text(existing + content, encoding="utf-8")
|
|
273
456
|
return {"written": filename}
|
|
274
|
-
else: # replace
|
|
275
|
-
filename = base_name
|
|
276
457
|
|
|
458
|
+
# Resolve (and validate) the document BEFORE any archive rename, so a
|
|
459
|
+
# rejected write leaves the existing canonical file untouched.
|
|
460
|
+
content, err = _resolve_slot_document(slot, fixed, params)
|
|
461
|
+
if err:
|
|
462
|
+
return err
|
|
463
|
+
|
|
464
|
+
archived = None
|
|
465
|
+
if mode == "new":
|
|
466
|
+
# Flipped rename: archive old file, write new content to canonical name
|
|
467
|
+
archived = _archive_old_file(directory, base_name)
|
|
468
|
+
filename = base_name
|
|
277
469
|
path = directory / filename
|
|
278
470
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
279
|
-
content = _ensure_str(params.get("content") or params.get("initialContent", ""))
|
|
280
471
|
path.write_text(content, encoding="utf-8")
|
|
281
472
|
result = {"written": filename}
|
|
282
473
|
if mode == "new" and archived:
|
|
@@ -290,10 +481,15 @@ def execute_create(slot: str, fixed: dict, params: dict,
|
|
|
290
481
|
base_name = resolve_write_target(slot, fixed, params)
|
|
291
482
|
directory = Path(output_dir)
|
|
292
483
|
directory.mkdir(parents=True, exist_ok=True)
|
|
484
|
+
# Resolve (and validate) BEFORE archiving so a rejected write leaves the
|
|
485
|
+
# existing canonical file untouched.
|
|
486
|
+
content, err = _resolve_slot_document(slot, fixed, params)
|
|
487
|
+
if err:
|
|
488
|
+
return err
|
|
293
489
|
archived = _archive_old_file(directory, base_name)
|
|
294
490
|
path = directory / base_name
|
|
295
491
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
296
|
-
path.write_text(
|
|
492
|
+
path.write_text(content, encoding="utf-8")
|
|
297
493
|
result = {"written": base_name}
|
|
298
494
|
if archived:
|
|
299
495
|
result["archived"] = archived
|
|
@@ -1247,6 +1247,57 @@ def test_on_deliver_list_order_and_on_failure(sf: SkillFlow, tmp_path: Path,
|
|
|
1247
1247
|
assert calls == []
|
|
1248
1248
|
|
|
1249
1249
|
|
|
1250
|
+
def test_on_deliver_item_retries_in_place(sf: SkillFlow, tmp_path: Path,
|
|
1251
|
+
monkeypatch):
|
|
1252
|
+
"""A failing on_deliver item with on_failure=retry re-runs THAT item up to
|
|
1253
|
+
its own max_retries IN PLACE; earlier items are not re-run, and the sequence
|
|
1254
|
+
never bubbles 'retry' to the caller (which would reset the whole step)."""
|
|
1255
|
+
sf._tool_loader = ToolLoader(
|
|
1256
|
+
Path(__file__).parent.parent / "src" / "skillflow" / "tools")
|
|
1257
|
+
sf._workspace = WorkspaceManager(str(tmp_path / "ws"),
|
|
1258
|
+
projects_base=str(tmp_path / "proj"))
|
|
1259
|
+
node = StepNode(id="s1", step_type="agent", output_mode="content",
|
|
1260
|
+
output_fixed={"out": "r.md"}, transitions=[Transition(to=None)])
|
|
1261
|
+
sf.register_graph(PipelineGraph(name="seq", begin="s1", steps=[node]))
|
|
1262
|
+
rid = sf.create_run("seq", {"project_id": "pid"})
|
|
1263
|
+
sf.start_run(rid)
|
|
1264
|
+
sf.advance_run(rid)
|
|
1265
|
+
token = sf.claim_next_step(rid).token
|
|
1266
|
+
|
|
1267
|
+
calls: list = []
|
|
1268
|
+
|
|
1269
|
+
# 'flaky' fails its first 2 attempts, succeeds on the 3rd.
|
|
1270
|
+
def fake(tok, nd, hook_name, spec):
|
|
1271
|
+
calls.append(spec["tool"])
|
|
1272
|
+
if spec["tool"] == "flaky":
|
|
1273
|
+
return {"passed": calls.count("flaky") >= 3}
|
|
1274
|
+
return {"passed": True}
|
|
1275
|
+
monkeypatch.setattr(sf, "_execute_tool_hook", fake)
|
|
1276
|
+
|
|
1277
|
+
r = sf._execute_lifecycle_hook(token, node, "on_deliver", [
|
|
1278
|
+
{"tool": "repo_apply"},
|
|
1279
|
+
{"tool": "flaky", "on_failure": "retry", "max_retries": 3},
|
|
1280
|
+
])
|
|
1281
|
+
# repo_apply ran ONCE (not re-run while flaky retried); flaky retried in place.
|
|
1282
|
+
assert calls == ["repo_apply", "flaky", "flaky", "flaky"]
|
|
1283
|
+
assert r["passed"] is True
|
|
1284
|
+
|
|
1285
|
+
# Exhausted retries → fail the STEP, never bubble 'retry'.
|
|
1286
|
+
calls.clear()
|
|
1287
|
+
|
|
1288
|
+
def always_fail(tok, nd, hook_name, spec):
|
|
1289
|
+
calls.append(spec["tool"])
|
|
1290
|
+
return {"passed": spec["tool"] != "boom"}
|
|
1291
|
+
monkeypatch.setattr(sf, "_execute_tool_hook", always_fail)
|
|
1292
|
+
r = sf._execute_lifecycle_hook(token, node, "on_deliver", [
|
|
1293
|
+
{"tool": "repo_apply"},
|
|
1294
|
+
{"tool": "boom", "on_failure": "retry", "max_retries": 2},
|
|
1295
|
+
])
|
|
1296
|
+
assert calls == ["repo_apply", "boom", "boom", "boom"] # 1 initial + 2 retries
|
|
1297
|
+
assert r["passed"] is False
|
|
1298
|
+
assert r.get("on_failure") == "fail" # NOT "retry" — no whole-step re-run
|
|
1299
|
+
|
|
1300
|
+
|
|
1250
1301
|
# ── Durable run trace ─────────────────────────────────────────────────
|
|
1251
1302
|
|
|
1252
1303
|
def test_trace_append_and_get(sf: SkillFlow):
|
|
@@ -1,17 +1,84 @@
|
|
|
1
1
|
"""Unit tests for recovery.py."""
|
|
2
2
|
|
|
3
3
|
import time
|
|
4
|
+
from pathlib import Path
|
|
4
5
|
|
|
5
6
|
import pytest
|
|
6
7
|
|
|
7
8
|
from skillflow.core import SkillFlow
|
|
8
9
|
from skillflow.graph import PipelineGraph, StepNode, Transition
|
|
10
|
+
from skillflow.tool_loader import ToolLoader
|
|
11
|
+
from skillflow.workspace import WorkspaceManager
|
|
12
|
+
|
|
13
|
+
_REAL_TOOLS = Path(__file__).parent.parent / "src" / "skillflow" / "tools"
|
|
9
14
|
|
|
10
15
|
|
|
11
16
|
def _agent(id: str, transitions=None):
|
|
12
17
|
return StepNode(id=id, step_type="agent", transitions=transitions or [])
|
|
13
18
|
|
|
14
19
|
|
|
20
|
+
def test_recover_never_reclaims_tool_with_zero_timeout(sf: SkillFlow):
|
|
21
|
+
"""A tool node with timeout_seconds=0 ('no timeout') is never reclaimed,
|
|
22
|
+
however old the claim — reclaiming a live no-timeout tool would relaunch it
|
|
23
|
+
concurrently with itself (the rampage)."""
|
|
24
|
+
graph = PipelineGraph(
|
|
25
|
+
name="test", begin="a",
|
|
26
|
+
steps=[StepNode(id="a", step_type="tool", tool_name="write",
|
|
27
|
+
timeout_seconds=0, transitions=[])],
|
|
28
|
+
)
|
|
29
|
+
sf.register_graph(graph)
|
|
30
|
+
run_id = sf.create_run("test")
|
|
31
|
+
sf.start_run(run_id)
|
|
32
|
+
long_ago = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time() - 99999))
|
|
33
|
+
with sf._lock:
|
|
34
|
+
sf._conn.execute(
|
|
35
|
+
"INSERT INTO skillflow_steps (run_id, step_id, step_config_json, "
|
|
36
|
+
"inputs_json, max_retries, status, claimed_at, created_at, updated_at) "
|
|
37
|
+
"VALUES (?, 'a', '{}', '{}', 3, 'claimed', ?, "
|
|
38
|
+
"datetime('now'), datetime('now'))",
|
|
39
|
+
(run_id, long_ago))
|
|
40
|
+
sf._conn.commit()
|
|
41
|
+
# Claimed 99999s ago, but timeout_seconds=0 → never stale.
|
|
42
|
+
assert sf.recover_stale_claims(stale_threshold_seconds=60) == []
|
|
43
|
+
st = sf._conn.execute(
|
|
44
|
+
"SELECT status FROM skillflow_steps WHERE run_id=? AND step_id='a' "
|
|
45
|
+
"ORDER BY id DESC LIMIT 1", (run_id,)).fetchone()
|
|
46
|
+
assert st["status"] == "claimed"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_tool_crash_fails_run_after_three(sf: SkillFlow, tmp_path,
|
|
50
|
+
monkeypatch):
|
|
51
|
+
"""A tool that crashes on EVERY execution fails the RUN after 3 crashes,
|
|
52
|
+
instead of looping forever via advance_run re-resolving into fresh instances
|
|
53
|
+
(regression: the per-instance _tool_reopen_count was reset each time
|
|
54
|
+
_claim_tool_step_in_tx opened a new instance)."""
|
|
55
|
+
sf._tool_loader = ToolLoader(_REAL_TOOLS)
|
|
56
|
+
sf._workspace = WorkspaceManager(str(tmp_path / "ws"),
|
|
57
|
+
projects_base=str(tmp_path / "proj"))
|
|
58
|
+
node = StepNode(id="boom", step_type="tool", tool_name="write",
|
|
59
|
+
transitions=[Transition(to=None)])
|
|
60
|
+
sf.register_graph(PipelineGraph(name="crash", begin="boom", steps=[node]))
|
|
61
|
+
run_id = sf.create_run("crash", {"project_id": "p"})
|
|
62
|
+
sf.start_run(run_id)
|
|
63
|
+
|
|
64
|
+
def _boom(*a, **k):
|
|
65
|
+
raise RuntimeError("boom")
|
|
66
|
+
monkeypatch.setattr(sf, "_execute_tool_inline", _boom)
|
|
67
|
+
|
|
68
|
+
for _ in range(25):
|
|
69
|
+
try:
|
|
70
|
+
sf.advance_run(run_id) # tool fast-path reopens then re-raises
|
|
71
|
+
except RuntimeError:
|
|
72
|
+
pass
|
|
73
|
+
if sf.get_run(run_id)["status"] == "failed":
|
|
74
|
+
break
|
|
75
|
+
assert sf.get_run(run_id)["status"] == "failed"
|
|
76
|
+
n = sf._conn.execute(
|
|
77
|
+
"SELECT COUNT(*) c FROM skillflow_steps WHERE run_id=? AND step_id='boom'",
|
|
78
|
+
(run_id,)).fetchone()["c"]
|
|
79
|
+
assert n == 1, f"crash should fail the run after 1 instance x3, got {n}"
|
|
80
|
+
|
|
81
|
+
|
|
15
82
|
def test_recover_respects_tool_timeout_seconds(sf: SkillFlow):
|
|
16
83
|
"""A TOOL step claimed longer than the flat threshold but WITHIN its
|
|
17
84
|
timeout_seconds is NOT reclaimed — a slow-but-alive tool (run_tests,
|
|
@@ -232,11 +232,12 @@ class TestWriteTools:
|
|
|
232
232
|
desc = tool["description"]
|
|
233
233
|
assert "Expected format:" in desc
|
|
234
234
|
assert '{"id": str, "description": str}' in desc
|
|
235
|
-
# id
|
|
235
|
+
# Structured slot: the id argument IS the content's id field — the
|
|
236
|
+
# coupling is structural now, not a prompt-hint plea.
|
|
236
237
|
write_tool = next(t for t in schemas if t["name"] == "write_task_card")
|
|
237
238
|
id_desc = write_tool["parameters"]["id"]["description"]
|
|
238
239
|
assert "Replaces * in tasks/*.json" in id_desc
|
|
239
|
-
assert "
|
|
240
|
+
assert "also written as the 'id' field" in id_desc
|
|
240
241
|
|
|
241
242
|
def test_glob_id_param_without_id_in_format(self):
|
|
242
243
|
"""When format lacks 'id', the id param description stays plain."""
|
|
@@ -257,3 +258,172 @@ class TestWriteTools:
|
|
|
257
258
|
# String entry omits format
|
|
258
259
|
result = _normalize_fixed_entry("x.json")
|
|
259
260
|
assert "format" not in result
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
VERDICT_FIXED = {
|
|
264
|
+
"verdict": {"file": "review_verdict.json", "on_exists": "new",
|
|
265
|
+
"format": '{"passed": bool, "feedback": str, "suggestions": [str, ...]}'}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class TestStructuredJsonSlots:
|
|
270
|
+
"""Structured .json slots: the document rides inside the tool arguments,
|
|
271
|
+
so JSON-in-a-string double-escaping (the review_verdict '\\.' incident)
|
|
272
|
+
is structurally impossible — and a string fallback is validated."""
|
|
273
|
+
|
|
274
|
+
# ── format-spec parser ──────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
def test_parse_format_verdict(self):
|
|
277
|
+
from skillflow.write_tools import _parse_format_spec
|
|
278
|
+
props = _parse_format_spec(
|
|
279
|
+
'{"passed": bool, "feedback": str, "suggestions": [str, ...]}')
|
|
280
|
+
assert props == {
|
|
281
|
+
"passed": {"type": "boolean"},
|
|
282
|
+
"feedback": {"type": "string"},
|
|
283
|
+
"suggestions": {"type": "array", "items": {"type": "string"}},
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
def test_parse_format_nested_arrays(self):
|
|
287
|
+
from skillflow.write_tools import _parse_format_spec
|
|
288
|
+
props = _parse_format_spec('{"execution_order": [[str, ...], ...]}')
|
|
289
|
+
assert props == {"execution_order": {
|
|
290
|
+
"type": "array", "items": {"type": "array",
|
|
291
|
+
"items": {"type": "string"}}}}
|
|
292
|
+
|
|
293
|
+
def test_parse_format_prose_becomes_description(self):
|
|
294
|
+
from skillflow.write_tools import _parse_format_spec
|
|
295
|
+
props = _parse_format_spec(
|
|
296
|
+
'{"id": str, "description": "ONE-LINE summary (max 80 chars)"}')
|
|
297
|
+
assert props["description"] == {
|
|
298
|
+
"type": "string",
|
|
299
|
+
"description": "ONE-LINE summary (max 80 chars)"}
|
|
300
|
+
|
|
301
|
+
def test_parse_format_named_shape_unparseable(self):
|
|
302
|
+
from skillflow.write_tools import _parse_format_spec
|
|
303
|
+
# References a named shape the mini-DSL can't resolve → None (tier 2)
|
|
304
|
+
assert _parse_format_spec(
|
|
305
|
+
'{"subtasks": [subtask, ...], "execution_order": [[str, ...], ...]}'
|
|
306
|
+
) is None
|
|
307
|
+
|
|
308
|
+
def test_parse_format_non_object(self):
|
|
309
|
+
from skillflow.write_tools import _parse_format_spec
|
|
310
|
+
assert _parse_format_spec("free prose format") is None
|
|
311
|
+
assert _parse_format_spec(None) is None
|
|
312
|
+
assert _parse_format_spec("") is None
|
|
313
|
+
|
|
314
|
+
# ── schema generation tiers ─────────────────────────────────
|
|
315
|
+
|
|
316
|
+
def test_tier1_per_field_params(self):
|
|
317
|
+
schemas = generate_write_tool_schemas("content", VERDICT_FIXED)
|
|
318
|
+
create = next(s for s in schemas if s["name"] == "create_verdict")
|
|
319
|
+
assert set(create["parameters"]) == {"passed", "feedback", "suggestions"}
|
|
320
|
+
assert create["parameters"]["passed"] == {
|
|
321
|
+
"type": "boolean", "required": True}
|
|
322
|
+
assert create["parameters"]["suggestions"]["items"] == {"type": "string"}
|
|
323
|
+
# no stringly content params anywhere
|
|
324
|
+
assert "content" not in create["parameters"]
|
|
325
|
+
assert "initialContent" not in create["parameters"]
|
|
326
|
+
assert "do NOT pass the document as a JSON-encoded string" in create["description"]
|
|
327
|
+
# edit stays textual (surgical string replace)
|
|
328
|
+
edit = next(s for s in schemas if s["name"] == "edit_verdict")
|
|
329
|
+
assert set(edit["parameters"]) == {"old_str", "new_str"}
|
|
330
|
+
|
|
331
|
+
def test_tier2_object_content_when_format_unparseable(self):
|
|
332
|
+
schemas = generate_write_tool_schemas("content", {
|
|
333
|
+
"manifest": {"file": "subtasks_manifest.json",
|
|
334
|
+
"format": '{"subtasks": [subtask, ...]}'}
|
|
335
|
+
})
|
|
336
|
+
write = next(s for s in schemas if s["name"] == "write_manifest")
|
|
337
|
+
assert write["parameters"]["content"]["type"] == "object"
|
|
338
|
+
create = next(s for s in schemas if s["name"] == "create_manifest")
|
|
339
|
+
assert create["parameters"]["initialContent"]["type"] == "object"
|
|
340
|
+
|
|
341
|
+
def test_tier3_text_slot_unchanged(self):
|
|
342
|
+
schemas = generate_write_tool_schemas("content", {
|
|
343
|
+
"notes": {"file": "research_notes.md", "format": "prose format"}
|
|
344
|
+
})
|
|
345
|
+
write = next(s for s in schemas if s["name"] == "write_notes")
|
|
346
|
+
assert write["parameters"]["content"] == {"type": "string",
|
|
347
|
+
"required": True}
|
|
348
|
+
|
|
349
|
+
# ── executor: per-field assembly ────────────────────────────
|
|
350
|
+
|
|
351
|
+
def test_create_assembles_document_from_fields(self, tmp_path):
|
|
352
|
+
from skillflow.write_tools import execute_create
|
|
353
|
+
import json as _json
|
|
354
|
+
res = execute_create("verdict", VERDICT_FIXED, {
|
|
355
|
+
"passed": True,
|
|
356
|
+
"feedback": r"the `.replace(/\.0$/, '')` fix is correct",
|
|
357
|
+
"suggestions": [],
|
|
358
|
+
}, str(tmp_path))
|
|
359
|
+
assert res == {"written": "review_verdict.json"}
|
|
360
|
+
data = _json.loads((tmp_path / "review_verdict.json").read_text())
|
|
361
|
+
# The backslash that used to kill the run survives intact
|
|
362
|
+
assert data["passed"] is True
|
|
363
|
+
assert "\\.0$" in data["feedback"]
|
|
364
|
+
|
|
365
|
+
def test_create_missing_field_is_tool_error(self, tmp_path):
|
|
366
|
+
from skillflow.write_tools import execute_create
|
|
367
|
+
res = execute_create("verdict", VERDICT_FIXED,
|
|
368
|
+
{"passed": True}, str(tmp_path))
|
|
369
|
+
assert "missing required field" in res["error"]
|
|
370
|
+
assert not (tmp_path / "review_verdict.json").exists()
|
|
371
|
+
|
|
372
|
+
def test_create_accepts_object_content(self, tmp_path):
|
|
373
|
+
from skillflow.write_tools import execute_create
|
|
374
|
+
import json as _json
|
|
375
|
+
res = execute_create("verdict", VERDICT_FIXED, {
|
|
376
|
+
"initialContent": {"passed": False, "feedback": "x",
|
|
377
|
+
"suggestions": ["y"]},
|
|
378
|
+
}, str(tmp_path))
|
|
379
|
+
assert res == {"written": "review_verdict.json"}
|
|
380
|
+
data = _json.loads((tmp_path / "review_verdict.json").read_text())
|
|
381
|
+
assert data["passed"] is False
|
|
382
|
+
|
|
383
|
+
def test_create_validates_string_content(self, tmp_path):
|
|
384
|
+
from skillflow.write_tools import execute_create
|
|
385
|
+
# The exact incident: an under-escaped backslash in a JSON string
|
|
386
|
+
bad = '{"passed": true, "feedback": "regex /\\.0$/ ok", "suggestions": []}'
|
|
387
|
+
assert "\\." in bad # single backslash-dot → invalid JSON escape
|
|
388
|
+
res = execute_create("verdict", VERDICT_FIXED,
|
|
389
|
+
{"initialContent": bad}, str(tmp_path))
|
|
390
|
+
assert "not valid JSON" in res["error"]
|
|
391
|
+
assert not (tmp_path / "review_verdict.json").exists()
|
|
392
|
+
|
|
393
|
+
def test_create_valid_string_still_accepted(self, tmp_path):
|
|
394
|
+
from skillflow.write_tools import execute_create
|
|
395
|
+
good = '{"passed": true, "feedback": "ok", "suggestions": []}'
|
|
396
|
+
res = execute_create("verdict", VERDICT_FIXED,
|
|
397
|
+
{"initialContent": good}, str(tmp_path))
|
|
398
|
+
assert res == {"written": "review_verdict.json"}
|
|
399
|
+
|
|
400
|
+
def test_rejected_write_preserves_existing_file(self, tmp_path):
|
|
401
|
+
from skillflow.write_tools import execute_create
|
|
402
|
+
(tmp_path / "review_verdict.json").write_text('{"passed": false}')
|
|
403
|
+
res = execute_create("verdict", VERDICT_FIXED,
|
|
404
|
+
{"initialContent": "{invalid"}, str(tmp_path))
|
|
405
|
+
assert "error" in res
|
|
406
|
+
# on_exists:new must NOT have archived the old file on a rejected write
|
|
407
|
+
assert (tmp_path / "review_verdict.json").read_text() == '{"passed": false}'
|
|
408
|
+
assert not (tmp_path / "review_verdict_1.json").exists()
|
|
409
|
+
|
|
410
|
+
def test_glob_id_fills_filename_and_field(self, tmp_path):
|
|
411
|
+
from skillflow.write_tools import execute_write
|
|
412
|
+
import json as _json
|
|
413
|
+
fixed = {"task_card": {"file": "tasks/*.json",
|
|
414
|
+
"format": '{"id": str, "description": str}'}}
|
|
415
|
+
res = execute_write("task_card", fixed, {
|
|
416
|
+
"id": "core_lib", "description": "build the core library",
|
|
417
|
+
}, str(tmp_path))
|
|
418
|
+
assert res == {"written": "tasks/core_lib.json"}
|
|
419
|
+
data = _json.loads((tmp_path / "tasks/core_lib.json").read_text())
|
|
420
|
+
assert data == {"id": "core_lib",
|
|
421
|
+
"description": "build the core library"}
|
|
422
|
+
|
|
423
|
+
def test_text_slot_write_unchanged(self, tmp_path):
|
|
424
|
+
from skillflow.write_tools import execute_write
|
|
425
|
+
res = execute_write("notes", {"notes": "research_notes.md"},
|
|
426
|
+
{"content": "plain text with \\. backslash"},
|
|
427
|
+
str(tmp_path))
|
|
428
|
+
assert res == {"written": "research_notes.md"}
|
|
429
|
+
assert "\\." in (tmp_path / "research_notes.md").read_text()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/plugins/linter/tools/skillflow_lint/impl.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/plugins/skill_converter/converter.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{skillflow_py-1.4.2 → skillflow_py-1.4.4}/src/skillflow/plugins/skill_converter/skill_converter.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|