skillflow-py 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. skillflow/__init__.py +97 -0
  2. skillflow/agent_registry.py +126 -0
  3. skillflow/context.py +205 -0
  4. skillflow/core.py +2037 -0
  5. skillflow/exceptions.py +55 -0
  6. skillflow/graph.py +754 -0
  7. skillflow/notifications.py +162 -0
  8. skillflow/outbox.py +33 -0
  9. skillflow/plugins/linter/__init__.py +189 -0
  10. skillflow/plugins/linter/cli.py +56 -0
  11. skillflow/plugins/linter/tools/skillflow_lint/impl.py +19 -0
  12. skillflow/plugins/linter/tools/skillflow_lint/tool.yaml +15 -0
  13. skillflow/plugins/skill_converter/AGENT.md +172 -0
  14. skillflow/plugins/skill_converter/__init__.py +18 -0
  15. skillflow/plugins/skill_converter/converter.py +181 -0
  16. skillflow/plugins/skill_converter/prompts/analyze_skill.md +32 -0
  17. skillflow/plugins/skill_converter/prompts/design_graph.md +47 -0
  18. skillflow/plugins/skill_converter/prompts/fix_issues.md +23 -0
  19. skillflow/plugins/skill_converter/skill_converter.yaml +80 -0
  20. skillflow/plugins/skill_runner/AGENT.md +161 -0
  21. skillflow/plugins/skill_runner/__init__.py +19 -0
  22. skillflow/plugins/skill_runner/runner.py +324 -0
  23. skillflow/recovery.py +78 -0
  24. skillflow/schema.py +137 -0
  25. skillflow/step_validation.py +114 -0
  26. skillflow/tool_loader.py +123 -0
  27. skillflow/tools/__init__.py +1 -0
  28. skillflow/tools/dir_tree/impl.py +32 -0
  29. skillflow/tools/dir_tree/tool.yaml +10 -0
  30. skillflow/tools/draft_commit/impl.py +63 -0
  31. skillflow/tools/draft_commit/tool.yaml +7 -0
  32. skillflow/tools/file_exists/impl.py +23 -0
  33. skillflow/tools/file_exists/tool.yaml +11 -0
  34. skillflow/tools/json_schema/impl.py +47 -0
  35. skillflow/tools/json_schema/tool.yaml +15 -0
  36. skillflow/tools/list_tree/impl.py +41 -0
  37. skillflow/tools/list_tree/tool.yaml +15 -0
  38. skillflow/tools/notify/impl.py +52 -0
  39. skillflow/tools/notify/tool.yaml +15 -0
  40. skillflow/tools/py_compile/impl.py +28 -0
  41. skillflow/tools/py_compile/tool.yaml +9 -0
  42. skillflow/tools/pytest/impl.py +25 -0
  43. skillflow/tools/pytest/tool.yaml +9 -0
  44. skillflow/tools/read_file/impl.py +26 -0
  45. skillflow/tools/read_file/tool.yaml +19 -0
  46. skillflow/tools/repo_apply/impl.py +53 -0
  47. skillflow/tools/repo_apply/tool.yaml +10 -0
  48. skillflow/tools/repo_validate/impl.py +68 -0
  49. skillflow/tools/repo_validate/tool.yaml +13 -0
  50. skillflow/tools/syntax_lint/impl.py +51 -0
  51. skillflow/tools/syntax_lint/tool.yaml +10 -0
  52. skillflow/tools/write/impl.py +24 -0
  53. skillflow/tools/write/tool.yaml +12 -0
  54. skillflow/validation.py +72 -0
  55. skillflow/workspace.py +192 -0
  56. skillflow/write_tools.py +240 -0
  57. skillflow_py-1.0.0.dist-info/METADATA +364 -0
  58. skillflow_py-1.0.0.dist-info/RECORD +61 -0
  59. skillflow_py-1.0.0.dist-info/WHEEL +5 -0
  60. skillflow_py-1.0.0.dist-info/licenses/LICENSE +21 -0
  61. skillflow_py-1.0.0.dist-info/top_level.txt +1 -0
skillflow/__init__.py ADDED
@@ -0,0 +1,97 @@
1
+ """skillflow — Transactional graph orchestrator for Python.
2
+
3
+ A standalone framework for defining and executing pipeline graphs
4
+ with transactional state management on SQLite. Only depends on
5
+ PyYAML.
6
+
7
+ Usage::
8
+
9
+ from skillflow import SkillFlow, PipelineGraph, StepRunner, StepResult
10
+
11
+ sf = SkillFlow(":memory:")
12
+ graph = PipelineGraph.from_yaml("pipeline.yaml")
13
+ sf.register_graph(graph)
14
+
15
+ run_id = sf.create_run("my_pipeline", {"project_id": "X"})
16
+ sf.start_run(run_id)
17
+
18
+ while True:
19
+ next_node = sf.advance_run(run_id)
20
+ if next_node is None:
21
+ break
22
+ claimed = sf.claim_next_step(run_id)
23
+ if claimed is None:
24
+ continue
25
+ result = await runner.execute(claimed)
26
+ sf.confirm_step(claimed.token, result)
27
+ """
28
+
29
+ # ── Public API re-exports ──────────────────────────────────────────
30
+
31
+ from skillflow.exceptions import (
32
+ SkillFlowError,
33
+ StepVersionConflict,
34
+ CycleLimitExceeded,
35
+ GraphValidationError,
36
+ OutputValidationError,
37
+ NoMatchingTransition,
38
+ )
39
+
40
+ from skillflow.graph import (
41
+ Transition,
42
+ StepNode,
43
+ EndCondition,
44
+ EndConditions,
45
+ EndResult,
46
+ PipelineGraph,
47
+ GraphResolver,
48
+ )
49
+
50
+ from skillflow.core import (
51
+ SkillFlow,
52
+ StepRunner,
53
+ ClaimToken,
54
+ ClaimedStep,
55
+ StepResult,
56
+ OutboxEvent,
57
+ )
58
+
59
+ from skillflow.validation import OutputValidator
60
+ from skillflow.outbox import OutboxConsumer
61
+ from skillflow.recovery import recover_stale_claims
62
+ from skillflow.notifications import NotificationBus, Notification
63
+ from skillflow.agent_registry import AgentRegistry, AgentConfig
64
+
65
+ __all__ = [
66
+ # Main class
67
+ "SkillFlow",
68
+ # Graph model
69
+ "PipelineGraph",
70
+ "GraphResolver",
71
+ "StepNode",
72
+ "Transition",
73
+ "EndCondition",
74
+ "EndConditions",
75
+ "EndResult",
76
+ # Protocol & values
77
+ "StepRunner",
78
+ "ClaimToken",
79
+ "ClaimedStep",
80
+ "StepResult",
81
+ "OutboxEvent",
82
+ # Utilities
83
+ "OutputValidator",
84
+ "OutboxConsumer",
85
+ "recover_stale_claims",
86
+ "NotificationBus",
87
+ "Notification",
88
+ "AgentRegistry",
89
+ "AgentConfig",
90
+ # Exceptions
91
+ "SkillFlowError",
92
+ "StepVersionConflict",
93
+ "CycleLimitExceeded",
94
+ "GraphValidationError",
95
+ "OutputValidationError",
96
+ "NoMatchingTransition",
97
+ ]
@@ -0,0 +1,126 @@
1
+ """Agent registry — validates graph agent_config references exist.
2
+
3
+ Host apps register agent configs at startup. The registry does NOT
4
+ know how to call LLMs — it only stores configs and resolves tool
5
+ schemas so the graph can be fully validated before any run starts.
6
+
7
+ Usage::
8
+
9
+ sf = SkillFlow(":memory:")
10
+ sf.register_agent_config("researcher", {
11
+ "model": "deepseek/deepseek-v4-flash",
12
+ "tools": ["read_file", "write", "list_tree"],
13
+ "system_prompt": "You are a researcher...",
14
+ })
15
+ # Graph validation will now catch missing agent_config refs.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass, field
21
+ from typing import Any
22
+
23
+
24
+ @dataclass
25
+ class AgentConfig:
26
+ """Opaque config for an agent referenced by name in a graph step.
27
+
28
+ skillflow never interprets these fields — they are passed through
29
+ to the host's StepRunner implementation. The only thing skillflow
30
+ does is validate that the name exists when registering a graph.
31
+ """
32
+
33
+ name: str
34
+ model: str = ""
35
+ tools: list[str] = field(default_factory=list)
36
+ config: dict[str, Any] = field(default_factory=dict)
37
+ # Resolved tool schemas (populated when tool_loader is available)
38
+ tool_schemas: dict[str, dict] = field(default_factory=dict)
39
+
40
+ def to_dict(self) -> dict:
41
+ return {
42
+ "name": self.name,
43
+ "model": self.model,
44
+ "tools": self.tools,
45
+ "config": self.config,
46
+ "tool_schemas": self.tool_schemas,
47
+ }
48
+
49
+
50
+ class AgentRegistry:
51
+ """Registry of agent configs indexed by name.
52
+
53
+ Validates that every graph step's ``agent_config`` references a
54
+ registered agent name. Optionally resolves tool schemas from a
55
+ ToolLoader so the host StepRunner receives everything it needs.
56
+ """
57
+
58
+ def __init__(self):
59
+ self._configs: dict[str, AgentConfig] = {}
60
+
61
+ # ── Registration ──────────────────────────────────────────
62
+
63
+ def register(self, name: str, *,
64
+ model: str = "",
65
+ tools: list[str] | None = None,
66
+ **kwargs) -> AgentConfig:
67
+ """Register an agent config.
68
+
69
+ Extra kwargs become ``config`` entries (e.g. template, temperature,
70
+ thinking settings — anything the host StepRunner needs).
71
+ """
72
+ cfg = AgentConfig(
73
+ name=name,
74
+ model=model,
75
+ tools=tools or [],
76
+ config=kwargs,
77
+ )
78
+ self._configs[name] = cfg
79
+ return cfg
80
+
81
+ def register_dict(self, name: str, d: dict) -> AgentConfig:
82
+ """Register from a flat dict (convenience for YAML-loaded configs).
83
+
84
+ ``model`` and ``tools`` are extracted; everything else goes into
85
+ ``config``.
86
+ """
87
+ d = dict(d)
88
+ model = d.pop("model", "")
89
+ tools = d.pop("tools", [])
90
+ return self.register(name, model=model, tools=tools, **d)
91
+
92
+ # ── Query ─────────────────────────────────────────────────
93
+
94
+ def get(self, name: str) -> AgentConfig | None:
95
+ return self._configs.get(name)
96
+
97
+ def list_names(self) -> list[str]:
98
+ return list(self._configs.keys())
99
+
100
+ def __contains__(self, name: str) -> bool:
101
+ return name in self._configs
102
+
103
+ def __len__(self) -> int:
104
+ return len(self._configs)
105
+
106
+ # ── Tool schema resolution ────────────────────────────────
107
+
108
+ def resolve_tool_schemas(self, tool_loader) -> None:
109
+ """Resolve tool schemas for all registered agent configs.
110
+
111
+ Called once after all configs and tools are registered.
112
+ For each tool name in each agent config, loads the tool
113
+ schema from the ToolLoader and caches it in tool_schemas.
114
+ """
115
+ for cfg in self._configs.values():
116
+ cfg.tool_schemas = {}
117
+ for tool_name in cfg.tools:
118
+ try:
119
+ cfg.tool_schemas[tool_name] = tool_loader.load_schema(tool_name)
120
+ except ImportError:
121
+ pass # tool not found — graph validation will catch
122
+
123
+ # ── Serialization ─────────────────────────────────────────
124
+
125
+ def to_dict(self) -> dict[str, dict]:
126
+ return {name: cfg.to_dict() for name, cfg in self._configs.items()}
skillflow/context.py ADDED
@@ -0,0 +1,205 @@
1
+ """Context resolution from step config context specs.
2
+
3
+ Resolves ``context`` entries from a step node's config into assembled
4
+ content for prompt injection. Supports five source types:
5
+
6
+ - ``{config: "name", output: "file"}`` — cross-config read
7
+ - ``{config: "name", step: "id", output: "file"}`` — cross-config from specific step
8
+ - ``{step: "id", file: "name", mode: "full"|"summary"|"interfaces"}`` — same-config read
9
+ - ``{step: "id"}`` — all files from that step's directory
10
+ - ``{tool: "name"}`` — dynamic tool call (e.g. dir_tree)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+
17
+
18
+ class ContextResolver:
19
+ """Resolves context sources into assembled content."""
20
+
21
+ def __init__(self, workspace_root: Path, tool_loader=None):
22
+ self._workspace_root = Path(workspace_root)
23
+ self._tool_loader = tool_loader
24
+
25
+ def resolve(self, specs: list[dict],
26
+ current_config: str = "") -> dict[str, str]:
27
+ """Resolve a list of context specs into a dict of label→content.
28
+
29
+ Returns a dict keyed by human-readable labels (e.g. "Project Brief",
30
+ "Architecture Design") suitable for prompt assembly.
31
+ """
32
+ result: dict[str, str] = {}
33
+ for spec in specs:
34
+ source = spec.get("source", spec)
35
+ label, content = self._resolve_one(source, current_config)
36
+ if content:
37
+ result[label] = content
38
+ return result
39
+
40
+ def _resolve_one(self, source: dict, current_config: str) -> tuple[str, str]:
41
+ if "config" in source:
42
+ return self._resolve_cross_config(source, current_config)
43
+ if "step" in source:
44
+ return self._resolve_step_output(source, current_config)
45
+ if "tool" in source:
46
+ return self._resolve_tool(source)
47
+ return "", ""
48
+
49
+ def _resolve_cross_config(self, source: dict, current_config: str) -> tuple[str, str]:
50
+ config_name = source["config"]
51
+ output_file = source["output"]
52
+ cfg_dir = self._workspace_root / config_name
53
+ if not cfg_dir.exists():
54
+ return "", ""
55
+
56
+ # If step is specified, read from that step's directory
57
+ if "step" in source:
58
+ step_dir = cfg_dir / source["step"]
59
+ if step_dir.exists() and step_dir.is_dir():
60
+ file_path = step_dir / output_file
61
+ if file_path.exists():
62
+ try:
63
+ content = file_path.read_text(encoding="utf-8", errors="replace")
64
+ label = f"{config_name}/{source['step']}/{output_file}"
65
+ return label, content
66
+ except Exception:
67
+ return "", ""
68
+
69
+ # Otherwise scan all step dirs (new-style) and legacy Outbox_Final_* dirs
70
+ for d in sorted(cfg_dir.glob("*")):
71
+ if d.name.endswith(".tmp") or d.name.startswith("Outbox_Draft"):
72
+ continue
73
+ if not d.is_dir():
74
+ continue
75
+ file_path = d / output_file
76
+ if file_path.exists():
77
+ try:
78
+ content = file_path.read_text(encoding="utf-8", errors="replace")
79
+ label = f"{config_name}/{output_file}"
80
+ return label, content
81
+ except Exception:
82
+ continue
83
+
84
+ return "", ""
85
+
86
+ def _resolve_step_output(self, source: dict, current_config: str) -> tuple[str, str]:
87
+ step_id = source["step"]
88
+ output_file = source.get("output") or source.get("file")
89
+ mode = source.get("mode", "full")
90
+ cfg = current_config or "dpe_default"
91
+
92
+ # New path: workspace/{project}/{config}/{step_id}/
93
+ step_dir = self._workspace_root / cfg / step_id
94
+
95
+ if not step_dir.exists() or not step_dir.is_dir():
96
+ return "", ""
97
+
98
+ # No specific file requested — return all files concatenated
99
+ if not output_file:
100
+ parts: list[str] = []
101
+ for f in sorted(step_dir.rglob("*")):
102
+ if f.is_file() and f.name != ".gitkeep":
103
+ try:
104
+ content = f.read_text(encoding="utf-8", errors="replace")
105
+ except Exception:
106
+ continue
107
+ rel = f.relative_to(step_dir)
108
+ parts.append(f"### {rel}\n{content}")
109
+ if not parts:
110
+ return "", ""
111
+ label = f"Step {step_id}"
112
+ return label, "\n\n".join(parts)
113
+
114
+ # Specific file: glob for patterns like "tasks/*.json"
115
+ if "*" in output_file:
116
+ parts = []
117
+ for f in sorted(step_dir.glob(output_file)):
118
+ if f.is_file():
119
+ try:
120
+ content = f.read_text(encoding="utf-8", errors="replace")
121
+ except Exception:
122
+ continue
123
+ parts.append(f"### {f.name}\n{content}")
124
+ if not parts:
125
+ return "", ""
126
+ label = f"Step {step_id} — {output_file}"
127
+ return label, "\n\n".join(parts)
128
+
129
+ file_path = step_dir / output_file
130
+ if not file_path.exists():
131
+ return "", ""
132
+
133
+ try:
134
+ content = file_path.read_text(encoding="utf-8", errors="replace")
135
+ except Exception:
136
+ return "", ""
137
+
138
+ if mode == "summary":
139
+ lines = content.splitlines()
140
+ if len(lines) > 100:
141
+ content = "\n".join(lines[:100]) + "\n... [summary truncated]"
142
+ elif mode == "interfaces":
143
+ content = self._extract_interfaces(content)
144
+
145
+ label = f"Step {step_id} — {output_file}"
146
+ return label, content
147
+
148
+ def _resolve_tool(self, source: dict) -> tuple[str, str]:
149
+ tool_name = source["tool"]
150
+ if not self._tool_loader:
151
+ return f"[{tool_name}]", ""
152
+
153
+ try:
154
+ fn = self._tool_loader.load_fn(tool_name)
155
+ result = fn(
156
+ workspace_root=str(self._workspace_root),
157
+ project_root=str(self._workspace_root / "project"),
158
+ )
159
+ if isinstance(result, dict):
160
+ content = result.get("tree", result.get("content", str(result)))
161
+ else:
162
+ content = str(result)
163
+ label = f"[{tool_name}]"
164
+ return label, content
165
+ except Exception:
166
+ return f"[{tool_name}]", ""
167
+
168
+ @staticmethod
169
+ def _extract_interfaces(content: str) -> str:
170
+ """Extract API/interface sections from architecture docs."""
171
+ import re
172
+ lines = content.splitlines()
173
+ result: list[str] = []
174
+ interface_keywords = {
175
+ "interface", "api", "contract", "endpoint", "module boundary",
176
+ "component", "data flow", "interaction"
177
+ }
178
+ in_section = False
179
+ section_depth = 0
180
+
181
+ for line in lines:
182
+ m = re.match(r'^(#{1,4})\s+(.*)', line)
183
+ if m:
184
+ header_text = m.group(2).lower()
185
+ depth = len(m.group(1))
186
+ if any(kw in header_text for kw in interface_keywords):
187
+ in_section = True
188
+ section_depth = depth
189
+ result.append(line)
190
+ elif in_section and depth <= section_depth:
191
+ in_section = False
192
+ if any(kw in header_text for kw in interface_keywords):
193
+ in_section = True
194
+ section_depth = depth
195
+ result.append(line)
196
+ elif in_section:
197
+ result.append(line)
198
+ elif in_section:
199
+ result.append(line)
200
+
201
+ if not result:
202
+ return "\n".join(lines[:150]) + "\n... [no interface sections found]"
203
+
204
+ extracted = "\n".join(result)
205
+ return extracted[:8000]