-
Notifications
You must be signed in to change notification settings - Fork 480
feat(artifact): implement Phase 2 Python Script Artifact (#526) #547
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,184 @@ | ||||||
| package cc.unitmesh.agent.artifact | ||||||
|
|
||||||
| /** | ||||||
| * PEP 723 Inline Script Metadata Parser & Generator. | ||||||
| * | ||||||
| * Parses and generates PEP 723 compliant inline metadata blocks in Python scripts. | ||||||
| * | ||||||
| * PEP 723 format example: | ||||||
| * ```python | ||||||
| * # /// script | ||||||
| * # requires-python = ">=3.11" | ||||||
| * # dependencies = [ | ||||||
| * # "requests>=2.28.0", | ||||||
| * # "pandas>=1.5.0", | ||||||
| * # ] | ||||||
| * # /// | ||||||
| * ``` | ||||||
| * | ||||||
| * @see <a href="https://peps.python.org/pep-0723/">PEP 723</a> | ||||||
| */ | ||||||
| object PEP723Parser { | ||||||
|
|
||||||
| /** | ||||||
| * Parsed result from a PEP 723 metadata block. | ||||||
| */ | ||||||
| data class PEP723Metadata( | ||||||
| /** Required Python version constraint, e.g. ">=3.11" */ | ||||||
| val requiresPython: String? = null, | ||||||
| /** List of dependency specifiers, e.g. ["requests>=2.28.0", "pandas>=1.5.0"] */ | ||||||
| val dependencies: List<String> = emptyList(), | ||||||
| /** AutoDev Unit custom metadata embedded in [tool.autodev-unit] */ | ||||||
| val autodevContext: Map<String, String> = emptyMap(), | ||||||
| /** The raw text of the entire metadata block (including comment prefixes) */ | ||||||
| val rawBlock: String? = null | ||||||
| ) | ||||||
|
|
||||||
| // ---- Parsing ---- | ||||||
|
|
||||||
| private val PEP723_BLOCK_PATTERN = Regex( | ||||||
| """#\s*///\s*script\s*\n(.*?)#\s*///""", | ||||||
| RegexOption.DOT_MATCHES_ALL | ||||||
| ) | ||||||
|
Comment on lines
+39
to
+41
|
||||||
|
|
||||||
| private val REQUIRES_PYTHON_PATTERN = Regex( | ||||||
| """requires-python\s*=\s*"([^"]*)"""" | ||||||
| ) | ||||||
|
|
||||||
| private val DEPENDENCIES_PATTERN = Regex( | ||||||
| """dependencies\s*=\s*\[(.*?)\]""", | ||||||
|
||||||
| """dependencies\s*=\s*\[(.*?)\]""", | |
| """dependencies\s*=\s*\[(.*)]""", |
Copilot
AI
Mar 8, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The parser currently only recognizes double-quoted TOML strings (e.g., requires-python = "...", # key = "..."). TOML also allows single-quoted strings, so valid PEP 723 metadata may be silently ignored. Consider extending REQUIRES_PYTHON_PATTERN / AUTODEV_KV_PATTERN to support both quote styles (or using a small TOML parser on the de-commented block).
Copilot
AI
Mar 8, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
parseDependencies() scans the entire input for dependencies = [...], so it can accidentally pick up a Python variable/string named dependencies outside the PEP 723 header. Since callers pass full script text (e.g., executor/agent), this can produce incorrect dependency installs. Consider first extracting the PEP 723 block with PEP723_BLOCK_PATTERN (or reusing parse()) and only searching within that block.
Outdated
Copilot
AI
Mar 8, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
stripMetadata() only trims leading \n after removing the block; if the script uses CRLF the result can start with a stray \r. Consider trimming both \r and \n (or calling trimStart() without args if that's acceptable here).
| return PEP723_BLOCK_PATTERN.replace(pythonContent, "").trimStart('\n') | |
| return PEP723_BLOCK_PATTERN.replace(pythonContent, "").trimStart('\r', '\n') |
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,213 @@ | ||||||||
| package cc.unitmesh.agent.subagent | ||||||||
|
|
||||||||
| import cc.unitmesh.agent.artifact.ArtifactContext | ||||||||
| import cc.unitmesh.agent.artifact.ConversationMessage | ||||||||
| import cc.unitmesh.agent.artifact.ModelInfo | ||||||||
| import cc.unitmesh.agent.artifact.PEP723Parser | ||||||||
| import cc.unitmesh.agent.core.SubAgent | ||||||||
| import cc.unitmesh.agent.model.AgentDefinition | ||||||||
| import cc.unitmesh.agent.model.PromptConfig | ||||||||
| import cc.unitmesh.agent.model.RunConfig | ||||||||
| import cc.unitmesh.agent.tool.ToolResult | ||||||||
| import cc.unitmesh.llm.LLMService | ||||||||
| import cc.unitmesh.devins.llm.Message | ||||||||
| import cc.unitmesh.devins.llm.MessageRole | ||||||||
| import cc.unitmesh.llm.ModelConfig | ||||||||
| import kotlinx.serialization.Serializable | ||||||||
|
|
||||||||
| /** | ||||||||
| * PythonArtifactAgent – Sub-agent responsible for generating | ||||||||
| * complete, self-contained Python scripts with PEP 723 inline metadata. | ||||||||
| * | ||||||||
| * The generated scripts follow the AutoDev Artifact convention and include | ||||||||
| * dependency declarations so that they can be executed with `uv run` or | ||||||||
| * after a simple `pip install`. | ||||||||
| * | ||||||||
| * @see <a href="https://github.com/phodal/auto-dev/issues/526">Issue #526</a> | ||||||||
| */ | ||||||||
| class PythonArtifactAgent( | ||||||||
| private val llmService: LLMService | ||||||||
| ) : SubAgent<PythonArtifactInput, ToolResult.AgentResult>( | ||||||||
| AgentDefinition( | ||||||||
| name = "PythonArtifactAgent", | ||||||||
| displayName = "Python Artifact Agent", | ||||||||
| description = "Generates self-contained Python scripts with PEP 723 metadata for the AutoDev Unit system", | ||||||||
| promptConfig = PromptConfig( | ||||||||
| systemPrompt = SYSTEM_PROMPT, | ||||||||
| queryTemplate = null, | ||||||||
| initialMessages = emptyList() | ||||||||
| ), | ||||||||
| modelConfig = ModelConfig.default(), | ||||||||
| runConfig = RunConfig( | ||||||||
| maxTurns = 1, | ||||||||
| maxTimeMinutes = 5, | ||||||||
| terminateOnError = true | ||||||||
| ) | ||||||||
| ) | ||||||||
| ) { | ||||||||
|
|
||||||||
| override fun validateInput(input: Map<String, Any>): PythonArtifactInput { | ||||||||
| val prompt = input["prompt"] as? String | ||||||||
| ?: throw IllegalArgumentException("'prompt' is required") | ||||||||
| val dependencies = (input["dependencies"] as? List<*>) | ||||||||
| ?.filterIsInstance<String>() | ||||||||
| ?: emptyList() | ||||||||
|
|
||||||||
| return PythonArtifactInput( | ||||||||
| prompt = prompt, | ||||||||
| dependencies = dependencies, | ||||||||
| requiresPython = input["requiresPython"] as? String ?: ">=3.11" | ||||||||
| ) | ||||||||
| } | ||||||||
|
|
||||||||
| override suspend fun execute( | ||||||||
| input: PythonArtifactInput, | ||||||||
| onProgress: (String) -> Unit | ||||||||
| ): ToolResult.AgentResult { | ||||||||
| onProgress("🐍 Generating Python script...") | ||||||||
coderabbitai[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||
|
|
||||||||
| val responseBuilder = StringBuilder() | ||||||||
|
|
||||||||
| val historyMessages = listOf( | ||||||||
| Message(role = MessageRole.SYSTEM, content = SYSTEM_PROMPT) | ||||||||
| ) | ||||||||
|
|
||||||||
| return try { | ||||||||
| llmService.streamPrompt( | ||||||||
| userPrompt = buildUserPrompt(input), | ||||||||
| historyMessages = historyMessages, | ||||||||
| compileDevIns = false | ||||||||
| ).collect { chunk -> | ||||||||
| responseBuilder.append(chunk) | ||||||||
| onProgress(chunk) | ||||||||
| } | ||||||||
|
|
||||||||
| val rawResponse = responseBuilder.toString() | ||||||||
| val scriptContent = extractPythonCode(rawResponse) | ||||||||
|
|
||||||||
| if (scriptContent.isNullOrBlank()) { | ||||||||
| return ToolResult.AgentResult( | ||||||||
| success = false, | ||||||||
| content = "Failed to extract Python code from LLM response." | ||||||||
| ) | ||||||||
| } | ||||||||
|
|
||||||||
| // Validate PEP 723 metadata is present; inject if missing | ||||||||
| val meta = PEP723Parser.parse(scriptContent) | ||||||||
| val finalScript = if (meta.rawBlock == null && input.dependencies.isNotEmpty()) { | ||||||||
|
||||||||
| PEP723Parser.injectMetadata( | ||||||||
| pythonContent = scriptContent, | ||||||||
| dependencies = input.dependencies, | ||||||||
| requiresPython = input.requiresPython | ||||||||
| ) | ||||||||
| } else { | ||||||||
| scriptContent | ||||||||
| } | ||||||||
|
Comment on lines
+95
to
+105
|
||||||||
|
|
||||||||
| onProgress("\n✅ Python script generated successfully.") | ||||||||
|
|
||||||||
| ToolResult.AgentResult( | ||||||||
| success = true, | ||||||||
| content = finalScript, | ||||||||
| metadata = mapOf( | ||||||||
| "type" to "python", | ||||||||
| "dependencies" to PEP723Parser.parseDependencies(finalScript).joinToString(","), | ||||||||
| "requiresPython" to (PEP723Parser.parse(finalScript).requiresPython ?: ">=3.11") | ||||||||
| ) | ||||||||
| ) | ||||||||
| } catch (e: Exception) { | ||||||||
| ToolResult.AgentResult( | ||||||||
| success = false, | ||||||||
| content = "Generation failed: ${e.message}" | ||||||||
| ) | ||||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
| override fun formatOutput(output: ToolResult.AgentResult): String = output.content | ||||||||
|
|
||||||||
| // ---- helpers ---- | ||||||||
|
|
||||||||
| private fun buildUserPrompt(input: PythonArtifactInput): String = buildString { | ||||||||
| appendLine(input.prompt) | ||||||||
| if (input.dependencies.isNotEmpty()) { | ||||||||
| appendLine() | ||||||||
| appendLine("Required dependencies: ${input.dependencies.joinToString(", ")}") | ||||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
| /** | ||||||||
| * Extract the Python code block from an LLM response. | ||||||||
| * Supports fenced code blocks (```python ... ```) and raw artifact XML. | ||||||||
| */ | ||||||||
| private fun extractPythonCode(response: String): String? { | ||||||||
| // Try autodev-artifact XML tag first | ||||||||
| val artifactPattern = Regex( | ||||||||
| """<autodev-artifact[^>]*type="application/autodev\.artifacts\.python"[^>]*>(.*?)</autodev-artifact>""", | ||||||||
| RegexOption.DOT_MATCHES_ALL | ||||||||
| ) | ||||||||
| artifactPattern.find(response)?.let { return it.groupValues[1].trim() } | ||||||||
|
|
||||||||
| // Try fenced python code block | ||||||||
| val fencedPattern = Regex( | ||||||||
| """```python\s*\n(.*?)```""", | ||||||||
|
||||||||
| """```python\s*\n(.*?)```""", | |
| // Matches ```python, optional trailing text, optional CRLF/newline, then captures everything up to the next ``` or end of string | |
| """```python[^\n\r]*\r?\n?(.*?)(```|$)""", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These regexes hardcode
\n, so scripts with CRLF (\r\n) may fail to match/strip/inject the PEP 723 block (and related sections). Consider allowing optional\rin newline parts to be robust across platforms.Severity: medium
Other Locations
mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/artifact/PEP723Parser.kt:56🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.