skillflow-py 1.2.0__tar.gz → 1.2.1__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.2.0/src/skillflow_py.egg-info → skillflow_py-1.2.1}/PKG-INFO +20 -2
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/README.md +19 -1
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/pyproject.toml +1 -1
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/core.py +104 -56
- {skillflow_py-1.2.0 → skillflow_py-1.2.1/src/skillflow_py.egg-info}/PKG-INFO +20 -2
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/tests/test_integration_configs.py +117 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/LICENSE +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/setup.cfg +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/__init__.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/agent_registry.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/context.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/convert_cli.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/exceptions.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/graph.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/notifications.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/outbox.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/plugins/linter/__init__.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/plugins/linter/cli.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/plugins/linter/tools/skillflow_lint/impl.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/plugins/linter/tools/skillflow_lint/tool.yaml +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/plugins/skill_converter/AGENT.md +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/plugins/skill_converter/__init__.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/plugins/skill_converter/converter.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/plugins/skill_converter/prompts/analyze_skill.md +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/plugins/skill_converter/prompts/design_graph.md +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/plugins/skill_converter/prompts/explain_design.md +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/plugins/skill_converter/prompts/fix_issues.md +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/plugins/skill_converter/skill_converter.yaml +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/plugins/skill_runner/AGENT.md +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/plugins/skill_runner/__init__.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/plugins/skill_runner/runner.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/read_tools.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/recovery.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/run_cli.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/schema.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/step_validation.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tool_loader.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/__init__.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/dir_tree/impl.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/dir_tree/tool.yaml +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/draft_commit/impl.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/draft_commit/tool.yaml +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/file_exists/impl.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/file_exists/tool.yaml +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/git_sync_pre/impl.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/git_sync_pre/tool.yaml +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/json_schema/impl.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/json_schema/tool.yaml +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/lint/impl.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/lint/tool.yaml +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/list_tree/impl.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/list_tree/tool.yaml +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/notify/impl.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/notify/tool.yaml +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/pytest/impl.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/pytest/tool.yaml +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/read_file/impl.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/read_file/tool.yaml +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/repo_apply/impl.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/repo_apply/tool.yaml +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/repo_validate/impl.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/repo_validate/tool.yaml +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/write/impl.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/tools/write/tool.yaml +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/validation.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/workspace.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow/write_tools.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow_py.egg-info/SOURCES.txt +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow_py.egg-info/dependency_links.txt +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow_py.egg-info/entry_points.txt +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow_py.egg-info/requires.txt +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/src/skillflow_py.egg-info/top_level.txt +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/tests/test_context.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/tests/test_core.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/tests/test_exceptions.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/tests/test_existing_repo_apply.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/tests/test_git_sync_pre.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/tests/test_graph.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/tests/test_integration.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/tests/test_migration_regression.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/tests/test_notifications.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/tests/test_outbox.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/tests/test_read_tools_exec.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/tests/test_read_tools_label.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/tests/test_recovery.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/tests/test_regression.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/tests/test_step_validation.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/tests/test_tool_loader.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/tests/test_tool_lock_contention.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/tests/test_tools.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/tests/test_validation.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/tests/test_workspace.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.2.1}/tests/test_write_tools.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: skillflow-py
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.1
|
|
4
4
|
Summary: Config-agnostic LLM pipeline graph executor — turn skills into flows
|
|
5
5
|
Author: Skillflow contributors
|
|
6
6
|
License-Expression: MIT
|
|
@@ -207,10 +207,15 @@ This powers both **inner review loops** (e.g. `review → implement`, `max_loop:
|
|
|
207
207
|
context:
|
|
208
208
|
- source: { step: "1" }
|
|
209
209
|
- source: { step: "2", mode: "interfaces" }
|
|
210
|
-
- source: { config: "meta", output: "brief.md" }
|
|
210
|
+
- source: { config: "meta", output: "brief.md" } # any step of another config
|
|
211
|
+
- source: { config: "meta", step: "finalize", output: "x.json" } # a SPECIFIC step's output
|
|
211
212
|
- source: { tool: "dir_tree" }
|
|
212
213
|
```
|
|
213
214
|
|
|
215
|
+
A cross-config source without `step` scans the other config's step dirs for the
|
|
216
|
+
file; adding `step` reads that one step's output (use it when only a specific
|
|
217
|
+
producing step is authoritative).
|
|
218
|
+
|
|
214
219
|
## Checkpoints
|
|
215
220
|
|
|
216
221
|
Agent steps can pause for human approval (`tests/fixtures/checkpoint_cycle.yaml`). On reject, the feedback is injected (via the `_feedback` channel) so the re-run knows *why* it was rejected:
|
|
@@ -268,6 +273,8 @@ end_conditions:
|
|
|
268
273
|
- type: node_reached
|
|
269
274
|
node: "5_review"
|
|
270
275
|
result: "completed"
|
|
276
|
+
require_completed: true # fire only once the node has COMPLETED, not merely
|
|
277
|
+
# been reached (i.e. become current_node)
|
|
271
278
|
- type: max_total_steps
|
|
272
279
|
limit: 200
|
|
273
280
|
- type: max_run_duration_seconds
|
|
@@ -276,6 +283,17 @@ end_conditions:
|
|
|
276
283
|
flag: { fatal_error: true }
|
|
277
284
|
```
|
|
278
285
|
|
|
286
|
+
`require_completed` (node_reached only) gates termination on the node's step
|
|
287
|
+
reaching `completed` status. Use it when the terminal node is a real agent/tool
|
|
288
|
+
step that must execute before the run ends — without it the condition fires as
|
|
289
|
+
soon as the node becomes `current_node`.
|
|
290
|
+
|
|
291
|
+
**Terminating a run needs an end condition.** A transition `to: null` does NOT by
|
|
292
|
+
itself end a run: with no resolvable target the run is marked **failed** ("no
|
|
293
|
+
matching transition"). To finish cleanly, give the terminal step `to: null` **and**
|
|
294
|
+
a `node_reached` end condition for that node (the pattern above). This applies to
|
|
295
|
+
`tool` and `gate` steps too, not just `agent` steps.
|
|
296
|
+
|
|
279
297
|
## Stale Claim Recovery
|
|
280
298
|
|
|
281
299
|
Built into `advance_run`. A claim whose worker died before calling `confirm`, and that is older than `stale_threshold_seconds` (default 300), is auto-reset to `pending` and re-claimed:
|
|
@@ -183,10 +183,15 @@ This powers both **inner review loops** (e.g. `review → implement`, `max_loop:
|
|
|
183
183
|
context:
|
|
184
184
|
- source: { step: "1" }
|
|
185
185
|
- source: { step: "2", mode: "interfaces" }
|
|
186
|
-
- source: { config: "meta", output: "brief.md" }
|
|
186
|
+
- source: { config: "meta", output: "brief.md" } # any step of another config
|
|
187
|
+
- source: { config: "meta", step: "finalize", output: "x.json" } # a SPECIFIC step's output
|
|
187
188
|
- source: { tool: "dir_tree" }
|
|
188
189
|
```
|
|
189
190
|
|
|
191
|
+
A cross-config source without `step` scans the other config's step dirs for the
|
|
192
|
+
file; adding `step` reads that one step's output (use it when only a specific
|
|
193
|
+
producing step is authoritative).
|
|
194
|
+
|
|
190
195
|
## Checkpoints
|
|
191
196
|
|
|
192
197
|
Agent steps can pause for human approval (`tests/fixtures/checkpoint_cycle.yaml`). On reject, the feedback is injected (via the `_feedback` channel) so the re-run knows *why* it was rejected:
|
|
@@ -244,6 +249,8 @@ end_conditions:
|
|
|
244
249
|
- type: node_reached
|
|
245
250
|
node: "5_review"
|
|
246
251
|
result: "completed"
|
|
252
|
+
require_completed: true # fire only once the node has COMPLETED, not merely
|
|
253
|
+
# been reached (i.e. become current_node)
|
|
247
254
|
- type: max_total_steps
|
|
248
255
|
limit: 200
|
|
249
256
|
- type: max_run_duration_seconds
|
|
@@ -252,6 +259,17 @@ end_conditions:
|
|
|
252
259
|
flag: { fatal_error: true }
|
|
253
260
|
```
|
|
254
261
|
|
|
262
|
+
`require_completed` (node_reached only) gates termination on the node's step
|
|
263
|
+
reaching `completed` status. Use it when the terminal node is a real agent/tool
|
|
264
|
+
step that must execute before the run ends — without it the condition fires as
|
|
265
|
+
soon as the node becomes `current_node`.
|
|
266
|
+
|
|
267
|
+
**Terminating a run needs an end condition.** A transition `to: null` does NOT by
|
|
268
|
+
itself end a run: with no resolvable target the run is marked **failed** ("no
|
|
269
|
+
matching transition"). To finish cleanly, give the terminal step `to: null` **and**
|
|
270
|
+
a `node_reached` end condition for that node (the pattern above). This applies to
|
|
271
|
+
`tool` and `gate` steps too, not just `agent` steps.
|
|
272
|
+
|
|
255
273
|
## Stale Claim Recovery
|
|
256
274
|
|
|
257
275
|
Built into `advance_run`. A claim whose worker died before calling `confirm`, and that is older than `stale_threshold_seconds` (default 300), is auto-reset to `pending` and re-claimed:
|
|
@@ -1817,16 +1817,20 @@ class SkillFlow:
|
|
|
1817
1817
|
items = flat
|
|
1818
1818
|
return items, False
|
|
1819
1819
|
|
|
1820
|
-
def
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
Inner review/verify loops (e.g. t_impl_review→t_impl, max_loop=3) are
|
|
1826
|
-
counted per (run, from, to) — i.e. shared across every iteration. That
|
|
1827
|
-
starves later tasks of retries. Resetting the body's edge counts when a
|
|
1828
|
-
new item is dispatched scopes the budget to the current iteration.
|
|
1820
|
+
def _loop_body_nodes(self, resolver, loop_step_id) -> set[str]:
|
|
1821
|
+
"""The set of node ids that form a loop's body: reachable from the
|
|
1822
|
+
loop's body transition, following transitions, excluding the loop node
|
|
1823
|
+
itself. Purely topological — derived from the graph, no config-specific
|
|
1824
|
+
names — so it stays config-agnostic.
|
|
1829
1825
|
"""
|
|
1826
|
+
node = resolver.get_node(loop_step_id)
|
|
1827
|
+
if not node:
|
|
1828
|
+
return set()
|
|
1829
|
+
body_target = None
|
|
1830
|
+
for t in node.transitions:
|
|
1831
|
+
if t.to:
|
|
1832
|
+
body_target = t.to
|
|
1833
|
+
break
|
|
1830
1834
|
body_nodes: set[str] = set()
|
|
1831
1835
|
stack = [body_target]
|
|
1832
1836
|
while stack:
|
|
@@ -1834,12 +1838,25 @@ class SkillFlow:
|
|
|
1834
1838
|
if not nid or nid in body_nodes or nid == loop_step_id:
|
|
1835
1839
|
continue
|
|
1836
1840
|
body_nodes.add(nid)
|
|
1837
|
-
|
|
1838
|
-
if not
|
|
1841
|
+
n = resolver.get_node(nid)
|
|
1842
|
+
if not n:
|
|
1839
1843
|
continue
|
|
1840
|
-
for t in
|
|
1844
|
+
for t in n.transitions:
|
|
1841
1845
|
if t.to and t.to != loop_step_id:
|
|
1842
1846
|
stack.append(t.to)
|
|
1847
|
+
return body_nodes
|
|
1848
|
+
|
|
1849
|
+
def _reset_loop_body_edge_counts(self, conn, run_id, resolver,
|
|
1850
|
+
loop_step_id, body_target):
|
|
1851
|
+
"""Clear edge counts for the loop body so each iteration gets a fresh
|
|
1852
|
+
retry budget.
|
|
1853
|
+
|
|
1854
|
+
Inner review/verify loops (e.g. t_impl_review→t_impl, max_loop=3) are
|
|
1855
|
+
counted per (run, from, to) — i.e. shared across every iteration. That
|
|
1856
|
+
starves later tasks of retries. Resetting the body's edge counts when a
|
|
1857
|
+
new item is dispatched scopes the budget to the current iteration.
|
|
1858
|
+
"""
|
|
1859
|
+
body_nodes = self._loop_body_nodes(resolver, loop_step_id)
|
|
1843
1860
|
if not body_nodes:
|
|
1844
1861
|
return
|
|
1845
1862
|
placeholders = ",".join("?" for _ in body_nodes)
|
|
@@ -1849,6 +1866,38 @@ class SkillFlow:
|
|
|
1849
1866
|
(run_id, *sorted(body_nodes)),
|
|
1850
1867
|
)
|
|
1851
1868
|
|
|
1869
|
+
def _credit_loop_current_item(self, conn, run_id: str, loop_step_id: str) -> None:
|
|
1870
|
+
"""Progression (write): mark the loop's current_item completed.
|
|
1871
|
+
|
|
1872
|
+
Called from confirm_step when a body cycle's terminal step routes back to
|
|
1873
|
+
the loop node — exactly once per completed body cycle, atomic with the
|
|
1874
|
+
terminal step's completion. _resolve_loop performs NO crediting (it only
|
|
1875
|
+
reads completed_items to pick the next item), so resolution is idempotent
|
|
1876
|
+
and the separate advance/claim transactions can't cause a spurious
|
|
1877
|
+
advance or skipped item.
|
|
1878
|
+
"""
|
|
1879
|
+
row = conn.execute(
|
|
1880
|
+
"SELECT completed_items, current_item FROM skillflow_loop_state "
|
|
1881
|
+
"WHERE run_id = ? AND loop_step_id = ?",
|
|
1882
|
+
(run_id, loop_step_id),
|
|
1883
|
+
).fetchone()
|
|
1884
|
+
if not row or not row["current_item"]:
|
|
1885
|
+
return
|
|
1886
|
+
completed: set[str] = set()
|
|
1887
|
+
if row["completed_items"]:
|
|
1888
|
+
try:
|
|
1889
|
+
completed = set(self._deserialize(row["completed_items"]))
|
|
1890
|
+
except Exception:
|
|
1891
|
+
pass
|
|
1892
|
+
if row["current_item"] in completed:
|
|
1893
|
+
return # idempotent — already credited
|
|
1894
|
+
completed.add(row["current_item"])
|
|
1895
|
+
conn.execute(
|
|
1896
|
+
"UPDATE skillflow_loop_state SET completed_items = ?, "
|
|
1897
|
+
"updated_at = datetime('now') WHERE run_id = ? AND loop_step_id = ?",
|
|
1898
|
+
(self._serialize(sorted(completed)), run_id, loop_step_id),
|
|
1899
|
+
)
|
|
1900
|
+
|
|
1852
1901
|
def _resolve_loop(self, conn, run: dict, resolver, loop_step_id: str) -> str | None:
|
|
1853
1902
|
"""Resolve a loop step to either its body or done transition.
|
|
1854
1903
|
|
|
@@ -1907,42 +1956,25 @@ class SkillFlow:
|
|
|
1907
1956
|
return t.to
|
|
1908
1957
|
return None
|
|
1909
1958
|
|
|
1910
|
-
# ── Completed set
|
|
1959
|
+
# ── Completed set — scoped to the LIVE manifest ───────────────────
|
|
1960
|
+
# Resolution is READ-ONLY w.r.t. progression: it never credits
|
|
1961
|
+
# completion (confirm_step does that via _credit_loop_current_item when a
|
|
1962
|
+
# body cycle returns to the loop). So re-entry — e.g. an extra scheduler
|
|
1963
|
+
# tick in the dispatch→claim gap — re-picks the SAME item with
|
|
1964
|
+
# current_item unchanged, never spuriously advancing or skipping work.
|
|
1911
1965
|
completed: set[str] = set()
|
|
1912
|
-
|
|
1966
|
+
current_item: str | None = None
|
|
1913
1967
|
if row:
|
|
1914
1968
|
if row["completed_items"]:
|
|
1915
1969
|
try:
|
|
1916
1970
|
completed = set(self._deserialize(row["completed_items"]))
|
|
1917
1971
|
except Exception:
|
|
1918
1972
|
pass
|
|
1919
|
-
|
|
1920
|
-
#
|
|
1921
|
-
#
|
|
1922
|
-
#
|
|
1923
|
-
|
|
1924
|
-
# least one item has finished (so we have a "previously dispatched"
|
|
1925
|
-
# item to mark completed).
|
|
1926
|
-
if not completed and row and row["current_item"] is None:
|
|
1927
|
-
try:
|
|
1928
|
-
old_items = self._deserialize(row["items_json"]) if row["items_json"] else []
|
|
1929
|
-
old_idx = row["current_index"]
|
|
1930
|
-
if isinstance(old_idx, int) and old_idx > 0:
|
|
1931
|
-
completed = set(old_items[:old_idx])
|
|
1932
|
-
if old_idx < len(old_items):
|
|
1933
|
-
prev_item = old_items[old_idx]
|
|
1934
|
-
elif isinstance(old_idx, int) and old_idx == 0:
|
|
1935
|
-
pass # first dispatch — nothing completed, nothing to mark
|
|
1936
|
-
except Exception:
|
|
1937
|
-
pass
|
|
1938
|
-
|
|
1939
|
-
# Mark the previously-dispatched item as completed.
|
|
1940
|
-
# prev_item is None on the very first dispatch (row is None),
|
|
1941
|
-
# so nothing is marked. On every subsequent entry, prev_item
|
|
1942
|
-
# was set by the previous dispatch and has now finished.
|
|
1943
|
-
if prev_item and prev_item not in completed:
|
|
1944
|
-
completed.add(prev_item)
|
|
1945
|
-
prev_item = None
|
|
1973
|
+
current_item = row["current_item"] or None
|
|
1974
|
+
# Drop superseded names from prior goal-loop rounds so completed_items
|
|
1975
|
+
# reflects only the active manifest (prevents the len(completed) overcount
|
|
1976
|
+
# and the scheduler's idx-out-of-range).
|
|
1977
|
+
completed &= set(items)
|
|
1946
1978
|
|
|
1947
1979
|
if row is None:
|
|
1948
1980
|
if not items:
|
|
@@ -1955,13 +1987,6 @@ class SkillFlow:
|
|
|
1955
1987
|
(run["id"], loop_step_id, self._serialize(items),
|
|
1956
1988
|
loop_cfg.item_as or "loop_item"),
|
|
1957
1989
|
)
|
|
1958
|
-
else:
|
|
1959
|
-
# Always persist the live manifest so context resolution sees it
|
|
1960
|
-
conn.execute(
|
|
1961
|
-
"UPDATE skillflow_loop_state SET items_json = ?, "
|
|
1962
|
-
"updated_at = datetime('now') WHERE run_id = ? AND loop_step_id = ?",
|
|
1963
|
-
(self._serialize(items), run["id"], loop_step_id),
|
|
1964
|
-
)
|
|
1965
1990
|
|
|
1966
1991
|
# ── Find first uncompleted item (manifest order) ────────────────
|
|
1967
1992
|
if not items:
|
|
@@ -1974,17 +1999,23 @@ class SkillFlow:
|
|
|
1974
1999
|
if next_item is None:
|
|
1975
2000
|
return _route_done()
|
|
1976
2001
|
|
|
1977
|
-
# ── Dispatch
|
|
2002
|
+
# ── Dispatch (idempotent) ─────────────────────────────────────────
|
|
2003
|
+
# Persist the live manifest + scoped completed set + the item to run.
|
|
2004
|
+
new_dispatch = (next_item != current_item)
|
|
1978
2005
|
conn.execute(
|
|
1979
|
-
"UPDATE skillflow_loop_state SET completed_items = ?, "
|
|
2006
|
+
"UPDATE skillflow_loop_state SET items_json = ?, completed_items = ?, "
|
|
1980
2007
|
"current_item = ?, updated_at = datetime('now') "
|
|
1981
2008
|
"WHERE run_id = ? AND loop_step_id = ?",
|
|
1982
|
-
(self._serialize(sorted(completed)),
|
|
1983
|
-
run["id"], loop_step_id),
|
|
1984
|
-
)
|
|
1985
|
-
self._reset_loop_body_edge_counts(
|
|
1986
|
-
conn, run["id"], resolver, loop_step_id, body_target
|
|
2009
|
+
(self._serialize(items), self._serialize(sorted(completed)),
|
|
2010
|
+
next_item, run["id"], loop_step_id),
|
|
1987
2011
|
)
|
|
2012
|
+
# Reset the body's per-iteration retry budget ONLY for a genuinely new
|
|
2013
|
+
# item — never on an idempotent re-entry for the same in-flight item,
|
|
2014
|
+
# which would wipe the body's mid-cycle edge counts.
|
|
2015
|
+
if new_dispatch:
|
|
2016
|
+
self._reset_loop_body_edge_counts(
|
|
2017
|
+
conn, run["id"], resolver, loop_step_id, body_target
|
|
2018
|
+
)
|
|
1988
2019
|
return body_target
|
|
1989
2020
|
|
|
1990
2021
|
def _resolve_next_in_tx(self, conn, run_id: str, step_id: str,
|
|
@@ -2043,7 +2074,19 @@ class SkillFlow:
|
|
|
2043
2074
|
tool_node = resolver.get_node(t.to)
|
|
2044
2075
|
if tool_node and not self._should_delegate_tool(tool_node.tool_name):
|
|
2045
2076
|
skip_tool = True
|
|
2046
|
-
if resolver.
|
|
2077
|
+
if resolver.is_loop(t.to):
|
|
2078
|
+
# PROGRESSION: credit the loop's current_item — but ONLY when
|
|
2079
|
+
# THIS completing step is inside the loop body (a body cycle
|
|
2080
|
+
# returning). An EXTERNAL step transitioning INTO the loop
|
|
2081
|
+
# (entry / goal-loop re-entry) must NOT credit: that would mark
|
|
2082
|
+
# the in-flight item complete and skip it. Topological check
|
|
2083
|
+
# (no config-specific names). Fires once per body cycle; a stray
|
|
2084
|
+
# re-tick at the loop node goes through advance_run only (no
|
|
2085
|
+
# credit), so resolution stays idempotent.
|
|
2086
|
+
if step_id in self._loop_body_nodes(resolver, t.to):
|
|
2087
|
+
self._credit_loop_current_item(conn, run_id, t.to)
|
|
2088
|
+
return None
|
|
2089
|
+
if resolver.is_gate(t.to) or skip_tool:
|
|
2047
2090
|
return None
|
|
2048
2091
|
|
|
2049
2092
|
# Check max_loop on this edge
|
|
@@ -3111,6 +3154,11 @@ class SkillFlow:
|
|
|
3111
3154
|
kwargs = dict(params)
|
|
3112
3155
|
kwargs.setdefault("workspace_root", project_root or "")
|
|
3113
3156
|
kwargs.setdefault("project_root", project_root or "")
|
|
3157
|
+
# Forward step/run identity so tools that want per-step state (e.g.
|
|
3158
|
+
# scratch-file tools) can isolate by step. Signature-filtered below, so
|
|
3159
|
+
# tools that don't declare these params are unaffected.
|
|
3160
|
+
kwargs.setdefault("step_id", step_id or "")
|
|
3161
|
+
kwargs.setdefault("run_id", run_id or "")
|
|
3114
3162
|
# SF-10: pass step staging/output dirs so read_file (and similar tools)
|
|
3115
3163
|
# can find files the agent just wrote (in .tmp) or files from previous
|
|
3116
3164
|
# retries (in the step's final dir). write_* tools write to .tmp; without
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: skillflow-py
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.1
|
|
4
4
|
Summary: Config-agnostic LLM pipeline graph executor — turn skills into flows
|
|
5
5
|
Author: Skillflow contributors
|
|
6
6
|
License-Expression: MIT
|
|
@@ -207,10 +207,15 @@ This powers both **inner review loops** (e.g. `review → implement`, `max_loop:
|
|
|
207
207
|
context:
|
|
208
208
|
- source: { step: "1" }
|
|
209
209
|
- source: { step: "2", mode: "interfaces" }
|
|
210
|
-
- source: { config: "meta", output: "brief.md" }
|
|
210
|
+
- source: { config: "meta", output: "brief.md" } # any step of another config
|
|
211
|
+
- source: { config: "meta", step: "finalize", output: "x.json" } # a SPECIFIC step's output
|
|
211
212
|
- source: { tool: "dir_tree" }
|
|
212
213
|
```
|
|
213
214
|
|
|
215
|
+
A cross-config source without `step` scans the other config's step dirs for the
|
|
216
|
+
file; adding `step` reads that one step's output (use it when only a specific
|
|
217
|
+
producing step is authoritative).
|
|
218
|
+
|
|
214
219
|
## Checkpoints
|
|
215
220
|
|
|
216
221
|
Agent steps can pause for human approval (`tests/fixtures/checkpoint_cycle.yaml`). On reject, the feedback is injected (via the `_feedback` channel) so the re-run knows *why* it was rejected:
|
|
@@ -268,6 +273,8 @@ end_conditions:
|
|
|
268
273
|
- type: node_reached
|
|
269
274
|
node: "5_review"
|
|
270
275
|
result: "completed"
|
|
276
|
+
require_completed: true # fire only once the node has COMPLETED, not merely
|
|
277
|
+
# been reached (i.e. become current_node)
|
|
271
278
|
- type: max_total_steps
|
|
272
279
|
limit: 200
|
|
273
280
|
- type: max_run_duration_seconds
|
|
@@ -276,6 +283,17 @@ end_conditions:
|
|
|
276
283
|
flag: { fatal_error: true }
|
|
277
284
|
```
|
|
278
285
|
|
|
286
|
+
`require_completed` (node_reached only) gates termination on the node's step
|
|
287
|
+
reaching `completed` status. Use it when the terminal node is a real agent/tool
|
|
288
|
+
step that must execute before the run ends — without it the condition fires as
|
|
289
|
+
soon as the node becomes `current_node`.
|
|
290
|
+
|
|
291
|
+
**Terminating a run needs an end condition.** A transition `to: null` does NOT by
|
|
292
|
+
itself end a run: with no resolvable target the run is marked **failed** ("no
|
|
293
|
+
matching transition"). To finish cleanly, give the terminal step `to: null` **and**
|
|
294
|
+
a `node_reached` end condition for that node (the pattern above). This applies to
|
|
295
|
+
`tool` and `gate` steps too, not just `agent` steps.
|
|
296
|
+
|
|
279
297
|
## Stale Claim Recovery
|
|
280
298
|
|
|
281
299
|
Built into `advance_run`. A claim whose worker died before calling `confirm`, and that is older than `stale_threshold_seconds` (default 300), is auto-reset to `pending` and re-claimed:
|
|
@@ -511,6 +511,123 @@ def test_loop_flat_list(sf_with_workspace):
|
|
|
511
511
|
assert items == ["one", "two"]
|
|
512
512
|
|
|
513
513
|
|
|
514
|
+
def test_resolve_loop_idempotent_without_body_completion(sf_with_workspace):
|
|
515
|
+
"""Re-entering _resolve_loop WITHOUT the body completing must re-pick the
|
|
516
|
+
SAME item and credit nothing — resolution is read-only w.r.t. progression.
|
|
517
|
+
|
|
518
|
+
Regression: the old code credited current_item on every entry, so a stray
|
|
519
|
+
re-resolve in the dispatch->claim gap skipped items and churned the body
|
|
520
|
+
claim (the version-15 / 7-claim behavior in the wild).
|
|
521
|
+
"""
|
|
522
|
+
import json
|
|
523
|
+
sf = sf_with_workspace
|
|
524
|
+
run_id = _loop_prepare(sf, [["a", "b", "c"]])
|
|
525
|
+
resolver = sf._get_resolver_for_run(run_id)
|
|
526
|
+
|
|
527
|
+
def _resolve():
|
|
528
|
+
with sf._tx() as conn:
|
|
529
|
+
run = conn.execute("SELECT * FROM skillflow_runs WHERE id = ?",
|
|
530
|
+
(run_id,)).fetchone()
|
|
531
|
+
return sf._resolve_loop(conn, run, resolver, "task_iterator")
|
|
532
|
+
|
|
533
|
+
def _state():
|
|
534
|
+
with sf._tx() as conn:
|
|
535
|
+
r = conn.execute("SELECT completed_items, current_item FROM "
|
|
536
|
+
"skillflow_loop_state WHERE run_id = ?",
|
|
537
|
+
(run_id,)).fetchone()
|
|
538
|
+
return json.loads(r["completed_items"] or "[]"), r["current_item"]
|
|
539
|
+
|
|
540
|
+
t1 = _resolve(); comp1, cur1 = _state()
|
|
541
|
+
t2 = _resolve(); comp2, cur2 = _state() # re-entry, body never ran
|
|
542
|
+
t3 = _resolve(); comp3, cur3 = _state()
|
|
543
|
+
assert t1 == t2 == t3 == "process_task"
|
|
544
|
+
assert cur1 == cur2 == cur3 == "a" # same item every time
|
|
545
|
+
assert comp1 == comp2 == comp3 == [] # nothing credited on resolution
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def test_loop_completed_scoped_to_live_manifest(sf_with_workspace):
|
|
549
|
+
"""completed_items is intersected with the live manifest on each resolve, so
|
|
550
|
+
superseded names from a prior goal-loop round are dropped (no overcount)."""
|
|
551
|
+
import json
|
|
552
|
+
sf = sf_with_workspace
|
|
553
|
+
run_id = _loop_prepare(sf, [["a", "b"]])
|
|
554
|
+
resolver = sf._get_resolver_for_run(run_id)
|
|
555
|
+
with sf._tx() as conn: # first resolve creates the row + dispatches "a"
|
|
556
|
+
run = conn.execute("SELECT * FROM skillflow_runs WHERE id = ?",
|
|
557
|
+
(run_id,)).fetchone()
|
|
558
|
+
sf._resolve_loop(conn, run, resolver, "task_iterator")
|
|
559
|
+
with sf._tx() as conn: # inject a stale name not in the manifest
|
|
560
|
+
conn.execute("UPDATE skillflow_loop_state SET completed_items = ? "
|
|
561
|
+
"WHERE run_id = ?",
|
|
562
|
+
(json.dumps(["a", "stale_old_task"]), run_id))
|
|
563
|
+
with sf._tx() as conn: # re-resolve: stale dropped, next item is "b"
|
|
564
|
+
run = conn.execute("SELECT * FROM skillflow_runs WHERE id = ?",
|
|
565
|
+
(run_id,)).fetchone()
|
|
566
|
+
nxt = sf._resolve_loop(conn, run, resolver, "task_iterator")
|
|
567
|
+
r = conn.execute("SELECT completed_items, current_item FROM "
|
|
568
|
+
"skillflow_loop_state WHERE run_id = ?", (run_id,)).fetchone()
|
|
569
|
+
assert nxt == "process_task"
|
|
570
|
+
assert "stale_old_task" not in json.loads(r["completed_items"])
|
|
571
|
+
assert r["current_item"] == "b"
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def test_loop_progression_credits_on_body_completion(sf_with_workspace):
|
|
575
|
+
"""End-to-end: each item is dispatched once, in order, and completed_items
|
|
576
|
+
grows only as body cycles complete (progression lives in confirm_step)."""
|
|
577
|
+
sf = sf_with_workspace
|
|
578
|
+
run_id = _loop_prepare(sf, [["a", "b", "c"]])
|
|
579
|
+
count, items = _drive_loop(sf, run_id)
|
|
580
|
+
assert sf.get_run(run_id)["status"] == "completed"
|
|
581
|
+
assert count == 3
|
|
582
|
+
assert items == ["a", "b", "c"]
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def test_loop_body_nodes_excludes_entry_and_loop(sf_with_workspace):
|
|
586
|
+
"""_loop_body_nodes is the loop body only — excludes the external entry step
|
|
587
|
+
and the loop node itself (topological, config-agnostic)."""
|
|
588
|
+
sf = sf_with_workspace
|
|
589
|
+
_load_and_register(sf, "loop_step.yaml")
|
|
590
|
+
run_id = sf.create_run("loop_step", project_id="test-loop-bn")
|
|
591
|
+
sf.start_run(run_id)
|
|
592
|
+
resolver = sf._get_resolver_for_run(run_id)
|
|
593
|
+
body = sf._loop_body_nodes(resolver, "task_iterator")
|
|
594
|
+
assert "process_task" in body # body node
|
|
595
|
+
assert "prepare" not in body # external entry — NOT body
|
|
596
|
+
assert "task_iterator" not in body # the loop node itself
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def test_loop_credit_only_on_body_return_not_entry(sf_with_workspace):
|
|
600
|
+
"""Progression credits current_item only when a BODY step returns to the
|
|
601
|
+
loop. An external step transitioning INTO the loop (entry / goal-loop
|
|
602
|
+
re-entry) must NOT credit — else it marks the in-flight item complete and
|
|
603
|
+
skips it."""
|
|
604
|
+
import json
|
|
605
|
+
sf = sf_with_workspace
|
|
606
|
+
run_id = _loop_prepare(sf, [["alpha", "beta"]])
|
|
607
|
+
resolver = sf._get_resolver_for_run(run_id)
|
|
608
|
+
# First dispatch → current_item = "alpha", nothing credited yet.
|
|
609
|
+
with sf._tx() as conn:
|
|
610
|
+
run = conn.execute("SELECT * FROM skillflow_runs WHERE id = ?",
|
|
611
|
+
(run_id,)).fetchone()
|
|
612
|
+
sf._resolve_loop(conn, run, resolver, "task_iterator")
|
|
613
|
+
|
|
614
|
+
def completed():
|
|
615
|
+
with sf._tx() as conn:
|
|
616
|
+
r = conn.execute("SELECT completed_items FROM skillflow_loop_state "
|
|
617
|
+
"WHERE run_id = ?", (run_id,)).fetchone()
|
|
618
|
+
return json.loads(r["completed_items"] or "[]")
|
|
619
|
+
|
|
620
|
+
assert completed() == []
|
|
621
|
+
# External entry step (prepare) resolving into the loop → MUST NOT credit.
|
|
622
|
+
with sf._tx() as conn:
|
|
623
|
+
sf._resolve_next_in_tx(conn, run_id, "prepare", {}, resolver)
|
|
624
|
+
assert completed() == []
|
|
625
|
+
# Body step (process_task) returning to the loop → credits current_item.
|
|
626
|
+
with sf._tx() as conn:
|
|
627
|
+
sf._resolve_next_in_tx(conn, run_id, "process_task", {}, resolver)
|
|
628
|
+
assert completed() == ["alpha"]
|
|
629
|
+
|
|
630
|
+
|
|
514
631
|
def test_loop_body_resolves_per_item_file(sf_with_workspace):
|
|
515
632
|
"""Loop body context resolves a per-item file via $current_task — the
|
|
516
633
|
mechanism dpe uses to scope each task_loop iteration to its task card."""
|
|
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.2.0 → skillflow_py-1.2.1}/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.2.0 → skillflow_py-1.2.1}/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.2.0 → skillflow_py-1.2.1}/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
|
|
File without changes
|
|
File without changes
|