Skip to content

Commit 529f81d

Browse files
committed
feat(artifact): implement Phase 2 Python Script Artifact (#526)
- Add PEP723Parser (commonMain): cross-platform PEP 723 inline script metadata parser/generator with parse, generate, injectMetadata, and stripMetadata operations. - Add PythonArtifactAgent (commonMain): SubAgent that uses LLM to generate PEP 723-compliant Python scripts. - Add PythonArtifactPackager (jvmMain): packages Python scripts with dependency resolution via uv (preferred) or pip fallback, and embeds AutoDev Unit context in the PEP 723 header. - Refactor PythonArtifactExecutor: replace 40-line local PEP 723 parsing logic with delegation to shared PEP723Parser. - Add PEP723ParserTest: 10 unit tests covering parse, generate, inject, and strip operations.
1 parent e986bfd commit 529f81d

File tree

5 files changed

+804
-35
lines changed

5 files changed

+804
-35
lines changed
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
package cc.unitmesh.agent.artifact
2+
3+
/**
4+
* PEP 723 Inline Script Metadata Parser & Generator.
5+
*
6+
* Parses and generates PEP 723 compliant inline metadata blocks in Python scripts.
7+
*
8+
* PEP 723 format example:
9+
* ```python
10+
* # /// script
11+
* # requires-python = ">=3.11"
12+
* # dependencies = [
13+
* # "requests>=2.28.0",
14+
* # "pandas>=1.5.0",
15+
* # ]
16+
* # ///
17+
* ```
18+
*
19+
* @see <a href="https://peps.python.org/pep-0723/">PEP 723</a>
20+
*/
21+
object PEP723Parser {
22+
23+
/**
24+
* Parsed result from a PEP 723 metadata block.
25+
*/
26+
data class PEP723Metadata(
27+
/** Required Python version constraint, e.g. ">=3.11" */
28+
val requiresPython: String? = null,
29+
/** List of dependency specifiers, e.g. ["requests>=2.28.0", "pandas>=1.5.0"] */
30+
val dependencies: List<String> = emptyList(),
31+
/** AutoDev Unit custom metadata embedded in [tool.autodev-unit] */
32+
val autodevContext: Map<String, String> = emptyMap(),
33+
/** The raw text of the entire metadata block (including comment prefixes) */
34+
val rawBlock: String? = null
35+
)
36+
37+
// ---- Parsing ----
38+
39+
private val PEP723_BLOCK_PATTERN = Regex(
40+
"""#\s*///\s*script\s*\n(.*?)#\s*///""",
41+
RegexOption.DOT_MATCHES_ALL
42+
)
43+
44+
private val REQUIRES_PYTHON_PATTERN = Regex(
45+
"""requires-python\s*=\s*"([^"]*)""""
46+
)
47+
48+
private val DEPENDENCIES_PATTERN = Regex(
49+
"""dependencies\s*=\s*\[(.*?)\]""",
50+
RegexOption.DOT_MATCHES_ALL
51+
)
52+
53+
private val DEP_ITEM_PATTERN = Regex("""["']([^"']+)["']""")
54+
55+
private val AUTODEV_SECTION_PATTERN = Regex(
56+
"""\[tool\.autodev-unit\]\s*\n(.*?)(?=#\s*///|\[tool\.|$)""",
57+
RegexOption.DOT_MATCHES_ALL
58+
)
59+
60+
private val AUTODEV_KV_PATTERN = Regex(
61+
"""#\s*(\S+)\s*=\s*"([^"]*)""""
62+
)
63+
64+
/**
65+
* Parse PEP 723 inline metadata from a Python script.
66+
*
67+
* @param pythonContent Full text of the Python script.
68+
* @return Parsed metadata, or a default empty metadata if no block is found.
69+
*/
70+
fun parse(pythonContent: String): PEP723Metadata {
71+
val blockMatch = PEP723_BLOCK_PATTERN.find(pythonContent)
72+
?: return PEP723Metadata()
73+
74+
val metadataBlock = blockMatch.groupValues[1]
75+
val rawBlock = blockMatch.value
76+
77+
// Parse requires-python
78+
val requiresPython = REQUIRES_PYTHON_PATTERN.find(metadataBlock)?.groupValues?.get(1)
79+
80+
// Parse dependencies
81+
val dependencies = parseDependencies(metadataBlock)
82+
83+
// Parse [tool.autodev-unit] section
84+
val autodevContext = parseAutodevContext(metadataBlock)
85+
86+
return PEP723Metadata(
87+
requiresPython = requiresPython,
88+
dependencies = dependencies,
89+
autodevContext = autodevContext,
90+
rawBlock = rawBlock
91+
)
92+
}
93+
94+
/**
95+
* Extract only the dependency list from a Python script (convenience method).
96+
*/
97+
fun parseDependencies(pythonContent: String): List<String> {
98+
val depsMatch = DEPENDENCIES_PATTERN.find(pythonContent) ?: return emptyList()
99+
val depsContent = depsMatch.groupValues[1]
100+
101+
return DEP_ITEM_PATTERN.findAll(depsContent)
102+
.map { it.groupValues[1] }
103+
.toList()
104+
}
105+
106+
private fun parseAutodevContext(metadataBlock: String): Map<String, String> {
107+
val sectionMatch = AUTODEV_SECTION_PATTERN.find(metadataBlock)
108+
?: return emptyMap()
109+
110+
val sectionContent = sectionMatch.groupValues[1]
111+
return AUTODEV_KV_PATTERN.findAll(sectionContent)
112+
.associate { it.groupValues[1] to it.groupValues[2] }
113+
}
114+
115+
// ---- Generation ----
116+
117+
/**
118+
* Generate a PEP 723 metadata header block.
119+
*
120+
* @param dependencies List of dependency specifiers (e.g. "requests>=2.28.0").
121+
* @param requiresPython Python version constraint (default ">=3.11").
122+
* @param autodevContext Optional AutoDev Unit context key-value pairs to embed.
123+
* @return The generated metadata block as a string, ready to prepend to a script.
124+
*/
125+
fun generate(
126+
dependencies: List<String> = emptyList(),
127+
requiresPython: String = ">=3.11",
128+
autodevContext: Map<String, String> = emptyMap()
129+
): String = buildString {
130+
appendLine("# /// script")
131+
appendLine("# requires-python = \"$requiresPython\"")
132+
133+
if (dependencies.isNotEmpty()) {
134+
appendLine("# dependencies = [")
135+
dependencies.forEach { dep ->
136+
appendLine("# \"$dep\",")
137+
}
138+
appendLine("# ]")
139+
}
140+
141+
if (autodevContext.isNotEmpty()) {
142+
appendLine("# [tool.autodev-unit]")
143+
autodevContext.forEach { (key, value) ->
144+
appendLine("# $key = \"$value\"")
145+
}
146+
}
147+
148+
appendLine("# ///")
149+
}
150+
151+
/**
152+
* Inject or replace a PEP 723 metadata block in a Python script.
153+
*
154+
* If the script already contains a PEP 723 block, it is replaced.
155+
* Otherwise the new block is prepended.
156+
*
157+
* @param pythonContent The original script content.
158+
* @param dependencies Dependency list.
159+
* @param requiresPython Python version constraint.
160+
* @param autodevContext Optional AutoDev context map.
161+
* @return The script with the metadata block injected/replaced.
162+
*/
163+
fun injectMetadata(
164+
pythonContent: String,
165+
dependencies: List<String> = emptyList(),
166+
requiresPython: String = ">=3.11",
167+
autodevContext: Map<String, String> = emptyMap()
168+
): String {
169+
val newBlock = generate(dependencies, requiresPython, autodevContext)
170+
171+
return if (PEP723_BLOCK_PATTERN.containsMatchIn(pythonContent)) {
172+
PEP723_BLOCK_PATTERN.replace(pythonContent, newBlock.trimEnd())
173+
} else {
174+
newBlock + "\n" + pythonContent
175+
}
176+
}
177+
178+
/**
179+
* Strip the PEP 723 metadata block from a Python script, returning only the code body.
180+
*/
181+
fun stripMetadata(pythonContent: String): String {
182+
return PEP723_BLOCK_PATTERN.replace(pythonContent, "").trimStart('\n')
183+
}
184+
}
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
package cc.unitmesh.agent.subagent
2+
3+
import cc.unitmesh.agent.artifact.ArtifactContext
4+
import cc.unitmesh.agent.artifact.ConversationMessage
5+
import cc.unitmesh.agent.artifact.ModelInfo
6+
import cc.unitmesh.agent.artifact.PEP723Parser
7+
import cc.unitmesh.agent.core.SubAgent
8+
import cc.unitmesh.agent.model.AgentDefinition
9+
import cc.unitmesh.agent.model.PromptConfig
10+
import cc.unitmesh.agent.model.RunConfig
11+
import cc.unitmesh.agent.tool.ToolResult
12+
import cc.unitmesh.llm.LLMService
13+
import cc.unitmesh.devins.llm.Message
14+
import cc.unitmesh.devins.llm.MessageRole
15+
import cc.unitmesh.llm.ModelConfig
16+
import kotlinx.serialization.Serializable
17+
18+
/**
19+
* PythonArtifactAgent – Sub-agent responsible for generating
20+
* complete, self-contained Python scripts with PEP 723 inline metadata.
21+
*
22+
* The generated scripts follow the AutoDev Artifact convention and include
23+
* dependency declarations so that they can be executed with `uv run` or
24+
* after a simple `pip install`.
25+
*
26+
* @see <a href="https://github.com/phodal/auto-dev/issues/526">Issue #526</a>
27+
*/
28+
class PythonArtifactAgent(
29+
private val llmService: LLMService
30+
) : SubAgent<PythonArtifactInput, ToolResult.AgentResult>(
31+
AgentDefinition(
32+
name = "PythonArtifactAgent",
33+
displayName = "Python Artifact Agent",
34+
description = "Generates self-contained Python scripts with PEP 723 metadata for the AutoDev Unit system",
35+
promptConfig = PromptConfig(
36+
systemPrompt = SYSTEM_PROMPT,
37+
queryTemplate = null,
38+
initialMessages = emptyList()
39+
),
40+
modelConfig = ModelConfig.default(),
41+
runConfig = RunConfig(
42+
maxTurns = 1,
43+
maxTimeMinutes = 5,
44+
terminateOnError = true
45+
)
46+
)
47+
) {
48+
49+
override fun validateInput(input: Map<String, Any>): PythonArtifactInput {
50+
val prompt = input["prompt"] as? String
51+
?: throw IllegalArgumentException("'prompt' is required")
52+
val dependencies = (input["dependencies"] as? List<*>)
53+
?.filterIsInstance<String>()
54+
?: emptyList()
55+
56+
return PythonArtifactInput(
57+
prompt = prompt,
58+
dependencies = dependencies,
59+
requiresPython = input["requiresPython"] as? String ?: ">=3.11"
60+
)
61+
}
62+
63+
override suspend fun execute(
64+
input: PythonArtifactInput,
65+
onProgress: (String) -> Unit
66+
): ToolResult.AgentResult {
67+
onProgress("🐍 Generating Python script...")
68+
69+
val responseBuilder = StringBuilder()
70+
71+
val historyMessages = listOf(
72+
Message(role = MessageRole.SYSTEM, content = SYSTEM_PROMPT)
73+
)
74+
75+
return try {
76+
llmService.streamPrompt(
77+
userPrompt = buildUserPrompt(input),
78+
historyMessages = historyMessages,
79+
compileDevIns = false
80+
).collect { chunk ->
81+
responseBuilder.append(chunk)
82+
onProgress(chunk)
83+
}
84+
85+
val rawResponse = responseBuilder.toString()
86+
val scriptContent = extractPythonCode(rawResponse)
87+
88+
if (scriptContent.isNullOrBlank()) {
89+
return ToolResult.AgentResult(
90+
success = false,
91+
content = "Failed to extract Python code from LLM response."
92+
)
93+
}
94+
95+
// Validate PEP 723 metadata is present; inject if missing
96+
val meta = PEP723Parser.parse(scriptContent)
97+
val finalScript = if (meta.rawBlock == null && input.dependencies.isNotEmpty()) {
98+
PEP723Parser.injectMetadata(
99+
pythonContent = scriptContent,
100+
dependencies = input.dependencies,
101+
requiresPython = input.requiresPython
102+
)
103+
} else {
104+
scriptContent
105+
}
106+
107+
onProgress("\n✅ Python script generated successfully.")
108+
109+
ToolResult.AgentResult(
110+
success = true,
111+
content = finalScript,
112+
metadata = mapOf(
113+
"type" to "python",
114+
"dependencies" to PEP723Parser.parseDependencies(finalScript).joinToString(","),
115+
"requiresPython" to (PEP723Parser.parse(finalScript).requiresPython ?: ">=3.11")
116+
)
117+
)
118+
} catch (e: Exception) {
119+
ToolResult.AgentResult(
120+
success = false,
121+
content = "Generation failed: ${e.message}"
122+
)
123+
}
124+
}
125+
126+
override fun formatOutput(output: ToolResult.AgentResult): String = output.content
127+
128+
// ---- helpers ----
129+
130+
private fun buildUserPrompt(input: PythonArtifactInput): String = buildString {
131+
appendLine(input.prompt)
132+
if (input.dependencies.isNotEmpty()) {
133+
appendLine()
134+
appendLine("Required dependencies: ${input.dependencies.joinToString(", ")}")
135+
}
136+
}
137+
138+
/**
139+
* Extract the Python code block from an LLM response.
140+
* Supports fenced code blocks (```python ... ```) and raw artifact XML.
141+
*/
142+
private fun extractPythonCode(response: String): String? {
143+
// Try autodev-artifact XML tag first
144+
val artifactPattern = Regex(
145+
"""<autodev-artifact[^>]*type="application/autodev\.artifacts\.python"[^>]*>(.*?)</autodev-artifact>""",
146+
RegexOption.DOT_MATCHES_ALL
147+
)
148+
artifactPattern.find(response)?.let { return it.groupValues[1].trim() }
149+
150+
// Try fenced python code block
151+
val fencedPattern = Regex(
152+
"""```python\s*\n(.*?)```""",
153+
RegexOption.DOT_MATCHES_ALL
154+
)
155+
fencedPattern.find(response)?.let { return it.groupValues[1].trim() }
156+
157+
// Fallback: if the whole response looks like Python code
158+
if (response.trimStart().startsWith("#") || response.trimStart().startsWith("import ") || response.trimStart().startsWith("from ")) {
159+
return response.trim()
160+
}
161+
162+
return null
163+
}
164+
165+
companion object {
166+
/**
167+
* System prompt guiding the LLM to generate PEP 723 compliant Python scripts.
168+
*/
169+
const val SYSTEM_PROMPT = """You are an expert Python developer specializing in creating self-contained, executable Python scripts.
170+
171+
## Rules
172+
173+
1. **PEP 723 Metadata** – Every script MUST begin with an inline metadata block:
174+
```python
175+
# /// script
176+
# requires-python = ">=3.11"
177+
# dependencies = [
178+
# "some-package>=1.0",
179+
# ]
180+
# ///
181+
```
182+
183+
2. **Self-Contained** – The script must run independently. All logic resides in a single file.
184+
185+
3. **Main Guard** – Always include:
186+
```python
187+
if __name__ == "__main__":
188+
main()
189+
```
190+
191+
4. **Clear Output** – Use `print()` to provide meaningful output to stdout.
192+
193+
5. **Error Handling** – Include basic try/except blocks for I/O, network, or file operations.
194+
195+
6. **No External Config** – Avoid reading from external config files. Use environment variables via `os.environ.get()` when necessary.
196+
197+
7. **Output Format** – Wrap the script in `<autodev-artifact identifier="..." type="application/autodev.artifacts.python" title="...">` tags.
198+
"""
199+
}
200+
}
201+
202+
/**
203+
* Input for PythonArtifactAgent
204+
*/
205+
@Serializable
206+
data class PythonArtifactInput(
207+
/** Natural-language description of what the script should do */
208+
val prompt: String,
209+
/** Pre-declared dependencies (may be empty) */
210+
val dependencies: List<String> = emptyList(),
211+
/** Python version constraint */
212+
val requiresPython: String = ">=3.11"
213+
)

0 commit comments

Comments
 (0)