skillflow-py 1.2.0__tar.gz → 1.3.0__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.3.0}/PKG-INFO +20 -2
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/README.md +19 -1
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/pyproject.toml +1 -1
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/core.py +138 -63
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/graph.py +8 -2
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/write_tools.py +120 -8
- {skillflow_py-1.2.0 → skillflow_py-1.3.0/src/skillflow_py.egg-info}/PKG-INFO +20 -2
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_core.py +6 -5
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_integration_configs.py +117 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_write_tools.py +97 -6
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/LICENSE +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/setup.cfg +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/__init__.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/agent_registry.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/context.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/convert_cli.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/exceptions.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/notifications.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/outbox.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/plugins/linter/__init__.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/plugins/linter/cli.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/plugins/linter/tools/skillflow_lint/impl.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/plugins/linter/tools/skillflow_lint/tool.yaml +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/plugins/skill_converter/AGENT.md +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/plugins/skill_converter/__init__.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/plugins/skill_converter/converter.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/plugins/skill_converter/prompts/analyze_skill.md +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/plugins/skill_converter/prompts/design_graph.md +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/plugins/skill_converter/prompts/explain_design.md +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/plugins/skill_converter/prompts/fix_issues.md +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/plugins/skill_converter/skill_converter.yaml +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/plugins/skill_runner/AGENT.md +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/plugins/skill_runner/__init__.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/plugins/skill_runner/runner.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/read_tools.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/recovery.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/run_cli.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/schema.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/step_validation.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tool_loader.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/__init__.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/dir_tree/impl.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/dir_tree/tool.yaml +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/draft_commit/impl.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/draft_commit/tool.yaml +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/file_exists/impl.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/file_exists/tool.yaml +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/git_sync_pre/impl.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/git_sync_pre/tool.yaml +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/json_schema/impl.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/json_schema/tool.yaml +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/lint/impl.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/lint/tool.yaml +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/list_tree/impl.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/list_tree/tool.yaml +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/notify/impl.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/notify/tool.yaml +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/pytest/impl.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/pytest/tool.yaml +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/read_file/impl.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/read_file/tool.yaml +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/repo_apply/impl.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/repo_apply/tool.yaml +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/repo_validate/impl.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/repo_validate/tool.yaml +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/write/impl.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/tools/write/tool.yaml +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/validation.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow/workspace.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow_py.egg-info/SOURCES.txt +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow_py.egg-info/dependency_links.txt +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow_py.egg-info/entry_points.txt +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow_py.egg-info/requires.txt +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/src/skillflow_py.egg-info/top_level.txt +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_context.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_exceptions.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_existing_repo_apply.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_git_sync_pre.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_graph.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_integration.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_migration_regression.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_notifications.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_outbox.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_read_tools_exec.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_read_tools_label.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_recovery.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_regression.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_step_validation.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_tool_loader.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_tool_lock_contention.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_tools.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_validation.py +0 -0
- {skillflow_py-1.2.0 → skillflow_py-1.3.0}/tests/test_workspace.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: skillflow-py
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.0
|
|
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:
|
|
@@ -861,10 +861,15 @@ class SkillFlow:
|
|
|
861
861
|
if k.startswith("[") and k not in inputs_with_tools["_resolved_context"]:
|
|
862
862
|
inputs_with_tools["_resolved_context"][k] = v
|
|
863
863
|
|
|
864
|
-
# Merge dynamic write tool schemas from
|
|
865
|
-
|
|
864
|
+
# Merge dynamic write tool schemas derived from the step's output
|
|
865
|
+
# contract (mode). Write-mode without fixed slots → create/edit
|
|
866
|
+
# (+ write only when allow_full_write); content-mode → write_/create_/
|
|
867
|
+
# edit_ per slot. Single source of truth for mutation tools.
|
|
868
|
+
if node.output_mode:
|
|
866
869
|
from skillflow.write_tools import generate_write_tool_schemas
|
|
867
|
-
for ws in generate_write_tool_schemas(
|
|
870
|
+
for ws in generate_write_tool_schemas(
|
|
871
|
+
node.output_mode, node.output_fixed,
|
|
872
|
+
allow_full_write=node.output_allow_full_write):
|
|
868
873
|
tool_schemas[ws["name"]] = ws
|
|
869
874
|
|
|
870
875
|
# Merge dynamic read tool schemas from graph's context specs
|
|
@@ -927,6 +932,14 @@ class SkillFlow:
|
|
|
927
932
|
tmp_dir = self._workspace.get_step_tmp_dir(
|
|
928
933
|
run["project_id"], run["graph_name"], node.id
|
|
929
934
|
)
|
|
935
|
+
# Reset staging at the start of each attempt so create/edit/write
|
|
936
|
+
# rebuild from a clean baseline (the repo). Without this, a partial
|
|
937
|
+
# result from a failed attempt pollutes the next one — e.g. a
|
|
938
|
+
# re-issued edit(old→new) would fail because 'old' was already
|
|
939
|
+
# replaced last attempt. Keeps step execution idempotent on retry.
|
|
940
|
+
import shutil
|
|
941
|
+
if tmp_dir.exists():
|
|
942
|
+
shutil.rmtree(str(tmp_dir))
|
|
930
943
|
tmp_dir.mkdir(parents=True, exist_ok=True)
|
|
931
944
|
inputs_with_tools["_output_dir"] = str(tmp_dir)
|
|
932
945
|
if node.output_fixed:
|
|
@@ -1817,16 +1830,20 @@ class SkillFlow:
|
|
|
1817
1830
|
items = flat
|
|
1818
1831
|
return items, False
|
|
1819
1832
|
|
|
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.
|
|
1833
|
+
def _loop_body_nodes(self, resolver, loop_step_id) -> set[str]:
|
|
1834
|
+
"""The set of node ids that form a loop's body: reachable from the
|
|
1835
|
+
loop's body transition, following transitions, excluding the loop node
|
|
1836
|
+
itself. Purely topological — derived from the graph, no config-specific
|
|
1837
|
+
names — so it stays config-agnostic.
|
|
1829
1838
|
"""
|
|
1839
|
+
node = resolver.get_node(loop_step_id)
|
|
1840
|
+
if not node:
|
|
1841
|
+
return set()
|
|
1842
|
+
body_target = None
|
|
1843
|
+
for t in node.transitions:
|
|
1844
|
+
if t.to:
|
|
1845
|
+
body_target = t.to
|
|
1846
|
+
break
|
|
1830
1847
|
body_nodes: set[str] = set()
|
|
1831
1848
|
stack = [body_target]
|
|
1832
1849
|
while stack:
|
|
@@ -1834,12 +1851,25 @@ class SkillFlow:
|
|
|
1834
1851
|
if not nid or nid in body_nodes or nid == loop_step_id:
|
|
1835
1852
|
continue
|
|
1836
1853
|
body_nodes.add(nid)
|
|
1837
|
-
|
|
1838
|
-
if not
|
|
1854
|
+
n = resolver.get_node(nid)
|
|
1855
|
+
if not n:
|
|
1839
1856
|
continue
|
|
1840
|
-
for t in
|
|
1857
|
+
for t in n.transitions:
|
|
1841
1858
|
if t.to and t.to != loop_step_id:
|
|
1842
1859
|
stack.append(t.to)
|
|
1860
|
+
return body_nodes
|
|
1861
|
+
|
|
1862
|
+
def _reset_loop_body_edge_counts(self, conn, run_id, resolver,
|
|
1863
|
+
loop_step_id, body_target):
|
|
1864
|
+
"""Clear edge counts for the loop body so each iteration gets a fresh
|
|
1865
|
+
retry budget.
|
|
1866
|
+
|
|
1867
|
+
Inner review/verify loops (e.g. t_impl_review→t_impl, max_loop=3) are
|
|
1868
|
+
counted per (run, from, to) — i.e. shared across every iteration. That
|
|
1869
|
+
starves later tasks of retries. Resetting the body's edge counts when a
|
|
1870
|
+
new item is dispatched scopes the budget to the current iteration.
|
|
1871
|
+
"""
|
|
1872
|
+
body_nodes = self._loop_body_nodes(resolver, loop_step_id)
|
|
1843
1873
|
if not body_nodes:
|
|
1844
1874
|
return
|
|
1845
1875
|
placeholders = ",".join("?" for _ in body_nodes)
|
|
@@ -1849,6 +1879,38 @@ class SkillFlow:
|
|
|
1849
1879
|
(run_id, *sorted(body_nodes)),
|
|
1850
1880
|
)
|
|
1851
1881
|
|
|
1882
|
+
def _credit_loop_current_item(self, conn, run_id: str, loop_step_id: str) -> None:
|
|
1883
|
+
"""Progression (write): mark the loop's current_item completed.
|
|
1884
|
+
|
|
1885
|
+
Called from confirm_step when a body cycle's terminal step routes back to
|
|
1886
|
+
the loop node — exactly once per completed body cycle, atomic with the
|
|
1887
|
+
terminal step's completion. _resolve_loop performs NO crediting (it only
|
|
1888
|
+
reads completed_items to pick the next item), so resolution is idempotent
|
|
1889
|
+
and the separate advance/claim transactions can't cause a spurious
|
|
1890
|
+
advance or skipped item.
|
|
1891
|
+
"""
|
|
1892
|
+
row = conn.execute(
|
|
1893
|
+
"SELECT completed_items, current_item FROM skillflow_loop_state "
|
|
1894
|
+
"WHERE run_id = ? AND loop_step_id = ?",
|
|
1895
|
+
(run_id, loop_step_id),
|
|
1896
|
+
).fetchone()
|
|
1897
|
+
if not row or not row["current_item"]:
|
|
1898
|
+
return
|
|
1899
|
+
completed: set[str] = set()
|
|
1900
|
+
if row["completed_items"]:
|
|
1901
|
+
try:
|
|
1902
|
+
completed = set(self._deserialize(row["completed_items"]))
|
|
1903
|
+
except Exception:
|
|
1904
|
+
pass
|
|
1905
|
+
if row["current_item"] in completed:
|
|
1906
|
+
return # idempotent — already credited
|
|
1907
|
+
completed.add(row["current_item"])
|
|
1908
|
+
conn.execute(
|
|
1909
|
+
"UPDATE skillflow_loop_state SET completed_items = ?, "
|
|
1910
|
+
"updated_at = datetime('now') WHERE run_id = ? AND loop_step_id = ?",
|
|
1911
|
+
(self._serialize(sorted(completed)), run_id, loop_step_id),
|
|
1912
|
+
)
|
|
1913
|
+
|
|
1852
1914
|
def _resolve_loop(self, conn, run: dict, resolver, loop_step_id: str) -> str | None:
|
|
1853
1915
|
"""Resolve a loop step to either its body or done transition.
|
|
1854
1916
|
|
|
@@ -1907,42 +1969,25 @@ class SkillFlow:
|
|
|
1907
1969
|
return t.to
|
|
1908
1970
|
return None
|
|
1909
1971
|
|
|
1910
|
-
# ── Completed set
|
|
1972
|
+
# ── Completed set — scoped to the LIVE manifest ───────────────────
|
|
1973
|
+
# Resolution is READ-ONLY w.r.t. progression: it never credits
|
|
1974
|
+
# completion (confirm_step does that via _credit_loop_current_item when a
|
|
1975
|
+
# body cycle returns to the loop). So re-entry — e.g. an extra scheduler
|
|
1976
|
+
# tick in the dispatch→claim gap — re-picks the SAME item with
|
|
1977
|
+
# current_item unchanged, never spuriously advancing or skipping work.
|
|
1911
1978
|
completed: set[str] = set()
|
|
1912
|
-
|
|
1979
|
+
current_item: str | None = None
|
|
1913
1980
|
if row:
|
|
1914
1981
|
if row["completed_items"]:
|
|
1915
1982
|
try:
|
|
1916
1983
|
completed = set(self._deserialize(row["completed_items"]))
|
|
1917
1984
|
except Exception:
|
|
1918
1985
|
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
|
|
1986
|
+
current_item = row["current_item"] or None
|
|
1987
|
+
# Drop superseded names from prior goal-loop rounds so completed_items
|
|
1988
|
+
# reflects only the active manifest (prevents the len(completed) overcount
|
|
1989
|
+
# and the scheduler's idx-out-of-range).
|
|
1990
|
+
completed &= set(items)
|
|
1946
1991
|
|
|
1947
1992
|
if row is None:
|
|
1948
1993
|
if not items:
|
|
@@ -1955,13 +2000,6 @@ class SkillFlow:
|
|
|
1955
2000
|
(run["id"], loop_step_id, self._serialize(items),
|
|
1956
2001
|
loop_cfg.item_as or "loop_item"),
|
|
1957
2002
|
)
|
|
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
2003
|
|
|
1966
2004
|
# ── Find first uncompleted item (manifest order) ────────────────
|
|
1967
2005
|
if not items:
|
|
@@ -1974,17 +2012,23 @@ class SkillFlow:
|
|
|
1974
2012
|
if next_item is None:
|
|
1975
2013
|
return _route_done()
|
|
1976
2014
|
|
|
1977
|
-
# ── Dispatch
|
|
2015
|
+
# ── Dispatch (idempotent) ─────────────────────────────────────────
|
|
2016
|
+
# Persist the live manifest + scoped completed set + the item to run.
|
|
2017
|
+
new_dispatch = (next_item != current_item)
|
|
1978
2018
|
conn.execute(
|
|
1979
|
-
"UPDATE skillflow_loop_state SET completed_items = ?, "
|
|
2019
|
+
"UPDATE skillflow_loop_state SET items_json = ?, completed_items = ?, "
|
|
1980
2020
|
"current_item = ?, updated_at = datetime('now') "
|
|
1981
2021
|
"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
|
|
2022
|
+
(self._serialize(items), self._serialize(sorted(completed)),
|
|
2023
|
+
next_item, run["id"], loop_step_id),
|
|
1987
2024
|
)
|
|
2025
|
+
# Reset the body's per-iteration retry budget ONLY for a genuinely new
|
|
2026
|
+
# item — never on an idempotent re-entry for the same in-flight item,
|
|
2027
|
+
# which would wipe the body's mid-cycle edge counts.
|
|
2028
|
+
if new_dispatch:
|
|
2029
|
+
self._reset_loop_body_edge_counts(
|
|
2030
|
+
conn, run["id"], resolver, loop_step_id, body_target
|
|
2031
|
+
)
|
|
1988
2032
|
return body_target
|
|
1989
2033
|
|
|
1990
2034
|
def _resolve_next_in_tx(self, conn, run_id: str, step_id: str,
|
|
@@ -2043,7 +2087,19 @@ class SkillFlow:
|
|
|
2043
2087
|
tool_node = resolver.get_node(t.to)
|
|
2044
2088
|
if tool_node and not self._should_delegate_tool(tool_node.tool_name):
|
|
2045
2089
|
skip_tool = True
|
|
2046
|
-
if resolver.
|
|
2090
|
+
if resolver.is_loop(t.to):
|
|
2091
|
+
# PROGRESSION: credit the loop's current_item — but ONLY when
|
|
2092
|
+
# THIS completing step is inside the loop body (a body cycle
|
|
2093
|
+
# returning). An EXTERNAL step transitioning INTO the loop
|
|
2094
|
+
# (entry / goal-loop re-entry) must NOT credit: that would mark
|
|
2095
|
+
# the in-flight item complete and skip it. Topological check
|
|
2096
|
+
# (no config-specific names). Fires once per body cycle; a stray
|
|
2097
|
+
# re-tick at the loop node goes through advance_run only (no
|
|
2098
|
+
# credit), so resolution stays idempotent.
|
|
2099
|
+
if step_id in self._loop_body_nodes(resolver, t.to):
|
|
2100
|
+
self._credit_loop_current_item(conn, run_id, t.to)
|
|
2101
|
+
return None
|
|
2102
|
+
if resolver.is_gate(t.to) or skip_tool:
|
|
2047
2103
|
return None
|
|
2048
2104
|
|
|
2049
2105
|
# Check max_loop on this edge
|
|
@@ -3057,9 +3113,11 @@ class SkillFlow:
|
|
|
3057
3113
|
ac = self.agent_registry.get(node.agent_config)
|
|
3058
3114
|
if ac:
|
|
3059
3115
|
allowed.update(ac.tools)
|
|
3060
|
-
if node.output_mode
|
|
3116
|
+
if node.output_mode:
|
|
3061
3117
|
from skillflow.write_tools import generate_write_tool_schemas
|
|
3062
|
-
for ws in generate_write_tool_schemas(
|
|
3118
|
+
for ws in generate_write_tool_schemas(
|
|
3119
|
+
node.output_mode, node.output_fixed,
|
|
3120
|
+
allow_full_write=node.output_allow_full_write):
|
|
3063
3121
|
allowed.add(ws["name"])
|
|
3064
3122
|
# Add read tool names from context specs (mode ∈ {tool, both})
|
|
3065
3123
|
if node.context:
|
|
@@ -3092,13 +3150,25 @@ class SkillFlow:
|
|
|
3092
3150
|
else:
|
|
3093
3151
|
return execute_write(slot, fixed, params, str(tmp_dir))
|
|
3094
3152
|
|
|
3095
|
-
|
|
3153
|
+
# Generic write-mode tools (mode: write, no fixed slots): create new
|
|
3154
|
+
# files / edit existing ones surgically. edit reads its baseline from
|
|
3155
|
+
# the consolidated repo (project_root) but writes the whole result into
|
|
3156
|
+
# staging — the repo is only ever mutated by on_deliver:repo_apply.
|
|
3157
|
+
if name in ("create", "edit", "write"):
|
|
3096
3158
|
if not self._workspace:
|
|
3097
3159
|
return {"error": "No workspace configured for write tool"}
|
|
3098
3160
|
pid = self._get_project_id(run_id)
|
|
3099
3161
|
gname = self._get_graph_name(run_id)
|
|
3100
3162
|
tmp_dir = self._workspace.get_step_tmp_dir(pid, gname, step_id)
|
|
3101
|
-
from skillflow.write_tools import
|
|
3163
|
+
from skillflow.write_tools import (execute_generic_create,
|
|
3164
|
+
execute_generic_edit,
|
|
3165
|
+
execute_generic_write)
|
|
3166
|
+
if name == "create":
|
|
3167
|
+
return execute_generic_create(params, str(tmp_dir),
|
|
3168
|
+
source_dir=project_root or "")
|
|
3169
|
+
if name == "edit":
|
|
3170
|
+
return execute_generic_edit(params, str(tmp_dir),
|
|
3171
|
+
source_dir=project_root or "")
|
|
3102
3172
|
return execute_generic_write(params, str(tmp_dir))
|
|
3103
3173
|
|
|
3104
3174
|
# finish_step — no-op completion signal; the host runner detects it and
|
|
@@ -3111,6 +3181,11 @@ class SkillFlow:
|
|
|
3111
3181
|
kwargs = dict(params)
|
|
3112
3182
|
kwargs.setdefault("workspace_root", project_root or "")
|
|
3113
3183
|
kwargs.setdefault("project_root", project_root or "")
|
|
3184
|
+
# Forward step/run identity so tools that want per-step state (e.g.
|
|
3185
|
+
# scratch-file tools) can isolate by step. Signature-filtered below, so
|
|
3186
|
+
# tools that don't declare these params are unaffected.
|
|
3187
|
+
kwargs.setdefault("step_id", step_id or "")
|
|
3188
|
+
kwargs.setdefault("run_id", run_id or "")
|
|
3114
3189
|
# SF-10: pass step staging/output dirs so read_file (and similar tools)
|
|
3115
3190
|
# can find files the agent just wrote (in .tmp) or files from previous
|
|
3116
3191
|
# retries (in the step's final dir). write_* tools write to .tmp; without
|
|
@@ -117,6 +117,7 @@ class StepNode:
|
|
|
117
117
|
context: list[dict] = field(default_factory=list)
|
|
118
118
|
output_mode: str = ""
|
|
119
119
|
output_fixed: dict = field(default_factory=dict)
|
|
120
|
+
output_allow_full_write: bool = False
|
|
120
121
|
validation: list[dict] = field(default_factory=list)
|
|
121
122
|
notify: list[str] | None = None # event types to push (None = outbox only)
|
|
122
123
|
lifecycle: dict[str, Any] = field(default_factory=dict)
|
|
@@ -264,6 +265,7 @@ class PipelineGraph:
|
|
|
264
265
|
context=s.get("context", []),
|
|
265
266
|
output_mode=(s.get("output") or {}).get("mode", "") or s.get("output_mode", ""),
|
|
266
267
|
output_fixed=(s.get("output") or {}).get("fixed", {}),
|
|
268
|
+
output_allow_full_write=bool((s.get("output") or {}).get("allow_full_write", False)),
|
|
267
269
|
validation=s.get("validation", []),
|
|
268
270
|
notify=s.get("notify"),
|
|
269
271
|
lifecycle=s.get("lifecycle", {}),
|
|
@@ -337,8 +339,12 @@ class PipelineGraph:
|
|
|
337
339
|
sd["context"] = s.context
|
|
338
340
|
if s.output_mode:
|
|
339
341
|
sd["output_mode"] = s.output_mode
|
|
340
|
-
if s.output_fixed:
|
|
341
|
-
sd["output"] = {
|
|
342
|
+
if s.output_fixed or s.output_allow_full_write:
|
|
343
|
+
sd["output"] = {}
|
|
344
|
+
if s.output_fixed:
|
|
345
|
+
sd["output"]["fixed"] = s.output_fixed
|
|
346
|
+
if s.output_allow_full_write:
|
|
347
|
+
sd["output"]["allow_full_write"] = True
|
|
342
348
|
if s.validation:
|
|
343
349
|
sd["validation"] = s.validation
|
|
344
350
|
if s.lifecycle:
|
|
@@ -87,31 +87,74 @@ def _archive_old_file(directory: Path, base_name: str) -> str | None:
|
|
|
87
87
|
|
|
88
88
|
|
|
89
89
|
def generate_write_tool_schemas(output_mode: str,
|
|
90
|
-
fixed: dict
|
|
91
|
-
|
|
90
|
+
fixed: dict,
|
|
91
|
+
allow_full_write: bool = False) -> list[dict]:
|
|
92
|
+
"""Generate tool schema dicts for write/create/edit/append tools.
|
|
92
93
|
|
|
93
94
|
Returns a list of dicts with 'name', 'description', 'parameters'.
|
|
95
|
+
|
|
96
|
+
For generic write-mode (``mode: write`` with no fixed slots) the default
|
|
97
|
+
tools are ``create`` (new files) + ``edit`` (surgical in-place change) —
|
|
98
|
+
NOT whole-file ``write``. Rewriting a whole existing file from the model's
|
|
99
|
+
(necessarily partial) view silently drops any region it didn't reproduce;
|
|
100
|
+
``edit`` carries the rest of the file through verbatim, so that failure mode
|
|
101
|
+
is impossible. Whole-file ``write`` is exposed only when ``allow_full_write``
|
|
102
|
+
is set on the step (rare: genuine from-scratch authorship).
|
|
94
103
|
"""
|
|
95
104
|
if output_mode == "write" and not fixed:
|
|
96
|
-
|
|
97
|
-
"name": "
|
|
98
|
-
"description":
|
|
105
|
+
tools = [{
|
|
106
|
+
"name": "create",
|
|
107
|
+
"description": (
|
|
108
|
+
"Create a NEW file with the given content. The 'file' param is a "
|
|
109
|
+
"repo-relative path (e.g. 'core/db_manager.py'). Fails if the file "
|
|
110
|
+
"already exists — use 'edit' to change an existing file."
|
|
111
|
+
),
|
|
99
112
|
"parameters": {
|
|
100
113
|
"file": {"type": "string", "required": True},
|
|
101
114
|
"content": {"type": "string", "required": True},
|
|
102
115
|
},
|
|
103
116
|
}, {
|
|
117
|
+
"name": "edit",
|
|
118
|
+
"description": (
|
|
119
|
+
"Surgically change an EXISTING file by replacing an exact, unique "
|
|
120
|
+
"snippet — use this to fix or update part of a file without "
|
|
121
|
+
"rewriting the whole thing (the rest is preserved verbatim). "
|
|
122
|
+
"'old_str' must appear exactly once; include surrounding context "
|
|
123
|
+
"to make it unique. Fails if the file is absent or 'old_str' isn't "
|
|
124
|
+
"found exactly once. For multiple changes, call edit repeatedly."
|
|
125
|
+
),
|
|
126
|
+
"parameters": {
|
|
127
|
+
"file": {"type": "string", "required": True},
|
|
128
|
+
"old_str": {"type": "string", "required": True,
|
|
129
|
+
"description": "Exact text to find (must appear exactly once)."},
|
|
130
|
+
"new_str": {"type": "string", "required": True,
|
|
131
|
+
"description": "Replacement text."},
|
|
132
|
+
},
|
|
133
|
+
}]
|
|
134
|
+
if allow_full_write:
|
|
135
|
+
tools.append({
|
|
136
|
+
"name": "write",
|
|
137
|
+
"description": ("Write a whole file, replacing it entirely if it "
|
|
138
|
+
"exists. Prefer 'edit' for existing files."),
|
|
139
|
+
"parameters": {
|
|
140
|
+
"file": {"type": "string", "required": True},
|
|
141
|
+
"content": {"type": "string", "required": True},
|
|
142
|
+
},
|
|
143
|
+
})
|
|
144
|
+
tools.append({
|
|
104
145
|
"name": "finish_step",
|
|
105
146
|
"description": (
|
|
106
147
|
"Signal that all required output files have been written and "
|
|
107
|
-
"the step is complete. Call this ONLY after all write
|
|
108
|
-
"in the current turn have been made — it must be the
|
|
148
|
+
"the step is complete. Call this ONLY after all create/edit/write "
|
|
149
|
+
"tool calls in the current turn have been made — it must be the "
|
|
150
|
+
"last call."
|
|
109
151
|
),
|
|
110
152
|
"parameters": {
|
|
111
153
|
"summary": {"type": "string", "required": False,
|
|
112
154
|
"description": "Brief summary of what was created or completed"},
|
|
113
155
|
},
|
|
114
|
-
}
|
|
156
|
+
})
|
|
157
|
+
return tools
|
|
115
158
|
|
|
116
159
|
if output_mode == "content":
|
|
117
160
|
tools = []
|
|
@@ -336,3 +379,72 @@ def execute_generic_write(params: dict, output_dir: str) -> dict:
|
|
|
336
379
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
337
380
|
path.write_text(_ensure_str(params.get("content", "")), encoding="utf-8")
|
|
338
381
|
return {"written": str(Path(*safe_parts))}
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def execute_generic_create(params: dict, output_dir: str,
|
|
385
|
+
source_dir: str = "") -> dict:
|
|
386
|
+
"""Execute a generic create(file, content) call for new repo files.
|
|
387
|
+
|
|
388
|
+
Writes the whole file into ``output_dir`` (staging). Refuses to create a
|
|
389
|
+
file that already exists in staging or in the consolidated repo
|
|
390
|
+
(``source_dir``) — existing files must be changed with ``edit``, so a
|
|
391
|
+
create can never silently clobber an existing file's contents.
|
|
392
|
+
"""
|
|
393
|
+
raw = params.get("file") or params.get("filename") or params.get("path", "")
|
|
394
|
+
safe_parts = normalize_repo_path(raw)
|
|
395
|
+
if not safe_parts:
|
|
396
|
+
return {"error": "Invalid filename: path traversal denied"}
|
|
397
|
+
rel = str(Path(*safe_parts))
|
|
398
|
+
staged = Path(output_dir) / rel
|
|
399
|
+
repo = (Path(source_dir) / rel) if source_dir else None
|
|
400
|
+
if staged.exists() or (repo is not None and repo.exists()):
|
|
401
|
+
return {"error": (f"create: '{rel}' already exists — use 'edit' to change "
|
|
402
|
+
f"an existing file (create is for new files only).")}
|
|
403
|
+
staged.parent.mkdir(parents=True, exist_ok=True)
|
|
404
|
+
staged.write_text(_ensure_str(params.get("content", "")), encoding="utf-8")
|
|
405
|
+
return {"written": rel}
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def execute_generic_edit(params: dict, output_dir: str,
|
|
409
|
+
source_dir: str = "") -> dict:
|
|
410
|
+
"""Execute a generic edit(file, old_str, new_str) call on an EXISTING file.
|
|
411
|
+
|
|
412
|
+
Baseline is staging-first: read ``output_dir/file`` if a prior edit/create
|
|
413
|
+
this same step already produced it (so repeated edits to one file compound),
|
|
414
|
+
else the consolidated repo ``source_dir/file``. Requires ``old_str`` to
|
|
415
|
+
match exactly once; writes the whole spliced result into staging, from where
|
|
416
|
+
promotion + repo_apply overwrites the repo copy. The repo is never edited in
|
|
417
|
+
place — ``edit`` only reads it as a baseline.
|
|
418
|
+
"""
|
|
419
|
+
raw = params.get("file") or params.get("filename") or params.get("path", "")
|
|
420
|
+
safe_parts = normalize_repo_path(raw)
|
|
421
|
+
if not safe_parts:
|
|
422
|
+
return {"error": "Invalid filename: path traversal denied"}
|
|
423
|
+
rel = str(Path(*safe_parts))
|
|
424
|
+
old_str = _ensure_str(params.get("old_str", ""))
|
|
425
|
+
new_str = _ensure_str(params.get("new_str", ""))
|
|
426
|
+
if not old_str:
|
|
427
|
+
return {"error": "edit: 'old_str' is required and must be non-empty"}
|
|
428
|
+
|
|
429
|
+
staged = Path(output_dir) / rel
|
|
430
|
+
repo = (Path(source_dir) / rel) if source_dir else None
|
|
431
|
+
if staged.exists():
|
|
432
|
+
src = staged
|
|
433
|
+
elif repo is not None and repo.exists():
|
|
434
|
+
src = repo
|
|
435
|
+
else:
|
|
436
|
+
return {"error": (f"edit: cannot edit '{rel}' — file does not exist "
|
|
437
|
+
f"(use 'create' for a new file).")}
|
|
438
|
+
|
|
439
|
+
content = src.read_text(encoding="utf-8")
|
|
440
|
+
occurrences = content.count(old_str)
|
|
441
|
+
if occurrences == 0:
|
|
442
|
+
return {"error": f"edit: 'old_str' not found in '{rel}'"}
|
|
443
|
+
if occurrences > 1:
|
|
444
|
+
return {"error": (f"edit: 'old_str' matches {occurrences} times in '{rel}' "
|
|
445
|
+
f"— include more surrounding context to make it unique")}
|
|
446
|
+
|
|
447
|
+
updated = content.replace(old_str, new_str, 1)
|
|
448
|
+
staged.parent.mkdir(parents=True, exist_ok=True)
|
|
449
|
+
staged.write_text(updated, encoding="utf-8")
|
|
450
|
+
return {"edited": rel}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: skillflow-py
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.0
|
|
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:
|