diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 0000000..973f733 --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,60 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + pull_request: + types: [opened] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) || + (github.event_name == 'pull_request' && (github.event.action == 'opened' || contains(github.event.pull_request.body, '@claude'))) + runs-on: ubuntu-latest + permissions: write-all + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@beta + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + github_token: ${{ secrets.GH_PAT }} + + # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4) + # model: "claude-opus-4-20250514" + + # Optional: Customize the trigger phrase (default: @claude) + # trigger_phrase: "/claude" + + # Optional: Trigger when specific user is assigned to an issue + # assignee_trigger: "claude-bot" + + # Optional: Allow Claude to run specific commands + allowed_tools: Bash + + # Optional: Add custom instructions for Claude to customize its behavior for your project + # custom_instructions: | + # Follow our coding standards + # Ensure all new code has tests + # Use TypeScript for new files + + # Optional: Custom environment variables for Claude + # claude_env: | + # NODE_ENV: test + + diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml new file mode 100644 index 0000000..f86303c --- /dev/null +++ b/.github/workflows/docker-build.yml @@ -0,0 +1,64 @@ +name: Build and Push Docker Image + +on: + push: + branches: + - main + paths: + - 'docker/Dockerfile.base' + - '.github/workflows/docker-build.yml' + pull_request: + branches: + - main + paths: + - 'docker/Dockerfile.base' + - '.github/workflows/docker-build.yml' + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }}/base + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=sha,prefix={{branch}} + type=raw,value=latest,enable={{is_default_branch}} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ./docker/Dockerfile.base + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..3035bc2 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,77 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + name: Test Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + + - name: Install dependencies + run: | + uv sync --extra dev + + - name: Run tests with coverage + run: | + uv run pytest --cov=src/agentman --cov-report=xml --cov-report=term-missing + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + file: ./coverage.xml + fail_ci_if_error: false + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + lint: + name: Code Quality + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + + - name: Install dependencies + run: | + uv sync --extra dev + + - name: Run black + run: | + uv run black src tests + + - name: Run isort + run: | + uv run isort --check-only src tests + + - name: Run pylint + run: | + uv run pylint src/agentman \ No newline at end of file diff --git a/.gitignore b/.gitignore index dde2021..ce1f3dd 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,5 @@ htmlcov/ .coverage # Generated files -agent/ \ No newline at end of file +agent/ +build/ \ No newline at end of file diff --git a/README.md b/README.md index 93357f0..6779310 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,13 @@ Get your first AI agent running in under 2 minutes: ```bash # 1. Install Agentman + +# From PyPI (recommended) pip install agentman-mcp +# Or, install the latest version from GitHub using uv +uv tool install git+https://github.com/o3-cloud/agentman.git@main#egg=agentman-mcp + # 2. Create and run your first agent mkdir my-agent && cd my-agent agentman run --from-agentfile -t my-agent . @@ -101,6 +106,8 @@ mkdir url-to-social && cd url-to-social ``` **2. Create an `Agentfile`:** + +*Option A: Dockerfile format (traditional)* ```dockerfile FROM yeahdongcn/agentman-base:latest MODEL anthropic/claude-3-sonnet @@ -126,6 +133,33 @@ SEQUENCE url_analyzer social_writer CMD ["python", "agent.py"] ``` +*Option B: YAML format (recommended for complex workflows)* +```yaml +# Agentfile.yml +apiVersion: v1 +kind: Agent +base: + model: anthropic/claude-3-sonnet +mcp_servers: +- name: fetch + command: uvx + args: + - mcp-server-fetch + transport: stdio +agents: +- name: url_analyzer + instruction: Given a URL, provide a comprehensive summary of the content + servers: + - fetch +- name: social_writer + instruction: Transform any text into a compelling 280-character social media post +chains: +- name: content_pipeline + sequence: + - url_analyzer + - social_writer +``` + **3. Build and run:** ```bash agentman run --from-agentfile -t url-to-social . @@ -186,6 +220,32 @@ The intuitive `Agentfile` syntax lets you focus on designing intelligent workflo | **๐Ÿ” Secure Secrets** | Environment-based secret handling with templates | | **๐Ÿงช Battle-Tested** | 91%+ test coverage ensures reliability | +### โœจ Environment Variable Expansion in Agentfiles + +Now you can use environment variables directly in your `Agentfile` and `Agentfile.yml` for more flexible and secure configurations. + +**Usage examples:** + +**Agentfile format** +```dockerfile +# Agentfile format +SECRET ALIYUN_API_KEY ${ALIYUN_API_KEY} +MCP_SERVER github-mcp-server +ENV GITHUB_PERSONAL_ACCESS_TOKEN ${GITHUB_TOKEN} +``` + +**YAML format** +```yaml +# YAML format +secrets: + - name: ALIYUN_API_KEY + value: ${ALIYUN_API_KEY} +mcp_servers: + - name: github-mcp-server + env: + GITHUB_PERSONAL_ACCESS_TOKEN: ${GITHUB_TOKEN} +``` + ### ๐ŸŒŸ What Makes Agentman Different? **Traditional AI Development:** @@ -267,6 +327,47 @@ EXPOSE 8080 # Expose ports CMD ["python", "agent.py"] # Container startup command ``` +### ๐Ÿ“„ File Format Support + +Agentman supports two file formats for defining your agent configurations: + +#### **Dockerfile-style Agentfile (Default)** +The traditional Docker-like syntax using an `Agentfile` without extension: + +```dockerfile +FROM yeahdongcn/agentman-base:latest +MODEL anthropic/claude-3-sonnet +FRAMEWORK fast-agent + +AGENT assistant +INSTRUCTION You are a helpful AI assistant +``` + +#### **YAML Agentfile** +Modern declarative YAML format using `Agentfile.yml` or `Agentfile.yaml`: + +```yaml +apiVersion: v1 +kind: Agent +base: + model: anthropic/claude-3-sonnet + framework: fast-agent +agents: +- name: assistant + instruction: You are a helpful AI assistant +``` + +**Key advantages of YAML format:** +- **๐ŸŽฏ Better structure** for complex multi-agent configurations +- **๐Ÿ“ Native support** for lists, nested objects, and comments +- **๐Ÿ” IDE support** with syntax highlighting and validation +- **๐Ÿ“Š Clear hierarchy** for routers, chains, and orchestrators + +**Usage:** +- **Build**: `agentman build .` (auto-detects format) +- **Run**: `agentman run --from-agentfile .` (works with both formats) +- **Convert**: Agentman can automatically convert between formats + ### Framework Configuration Choose between supported AI agent frameworks: @@ -290,6 +391,7 @@ FRAMEWORK agno # Alternative: Agno framework Define external MCP servers that provide tools and capabilities: +**Dockerfile format:** ```dockerfile MCP_SERVER filesystem COMMAND uvx @@ -298,10 +400,23 @@ TRANSPORT stdio ENV PATH_PREFIX /app/data ``` +**YAML format:** +```yaml +mcp_servers: +- name: filesystem + command: uvx + args: + - mcp-server-filesystem + transport: stdio + env: + PATH_PREFIX: /app/data +``` + ### Agent Definitions Create individual agents with specific roles and capabilities: +**Dockerfile format:** ```dockerfile AGENT assistant INSTRUCTION You are a helpful AI assistant specialized in data analysis @@ -311,23 +426,120 @@ USE_HISTORY true HUMAN_INPUT false ``` +**YAML format:** +```yaml +agents: +- name: assistant + instruction: You are a helpful AI assistant specialized in data analysis + servers: + - filesystem + - brave + model: anthropic/claude-3-sonnet + use_history: true + human_input: false +``` + +### Structured Output Format + +Define validation schemas for agent outputs using JSONSchema: + +**Dockerfile format:** +```dockerfile +AGENT data_analyzer +INSTRUCTION Analyze data and return structured results +OUTPUT_FORMAT json_schema {"type":"object","properties":{"status":{"type":"string","enum":["success","error"]},"data":{"type":"object"}},"required":["status","data"]} + +AGENT file_processor +INSTRUCTION Process files according to predefined schema +OUTPUT_FORMAT schema_file ./schemas/processing_schema.yaml +``` + +**YAML format:** +```yaml +agents: +- name: data_analyzer + instruction: Analyze data and return structured results + output_format: + type: json_schema + schema: + type: object + properties: + status: + type: string + enum: [success, error] + data: + type: object + properties: + count: + type: number + items: + type: array + items: + type: string + required: [status, data] + +- name: file_processor + instruction: Process files according to predefined schema + output_format: + type: schema_file + file: ./schemas/processing_schema.yaml +``` + +**Schema Types:** +- `json_schema`: Inline JSONSchema definition in JSON format (Dockerfile) or YAML format (YAML Agentfile) +- `schema_file`: Reference to external `.json` or `.yaml` schema file + +**Benefits:** +- **Type Safety**: Validate agent outputs against predefined schemas +- **Documentation**: Schemas serve as output documentation +- **IDE Support**: JSONSchema provides autocomplete and validation +- **Standards**: Uses standard JSONSchema specification + ### Workflow Orchestration **Chains** (Sequential processing): + +*Dockerfile format:* ```dockerfile CHAIN data_pipeline SEQUENCE data_loader data_processor data_exporter CUMULATIVE true ``` +*YAML format:* +```yaml +chains: +- name: data_pipeline + sequence: + - data_loader + - data_processor + - data_exporter + cumulative: true +``` + **Routers** (Conditional routing): + +*Dockerfile format:* ```dockerfile ROUTER query_router AGENTS sql_agent api_agent file_agent INSTRUCTION Route queries based on data source type ``` +*YAML format:* +```yaml +routers: +- name: query_router + agents: + - sql_agent + - api_agent + - file_agent + instruction: Route queries based on data source type +``` + **Orchestrators** (Complex coordination): + +*Dockerfile format:* ```dockerfile ORCHESTRATOR project_manager AGENTS developer tester deployer @@ -336,10 +548,24 @@ PLAN_ITERATIONS 5 HUMAN_INPUT true ``` +*YAML format:* +```yaml +orchestrators: +- name: project_manager + agents: + - developer + - tester + - deployer + plan_type: iterative + plan_iterations: 5 + human_input: true +``` + ### Secrets Management Secure handling of API keys and sensitive configuration: +**Dockerfile format:** ```dockerfile # Environment variable references SECRET OPENAI_API_KEY @@ -355,6 +581,23 @@ BASE_URL https://api.example.com TIMEOUT 30 ``` +**YAML format:** +```yaml +secrets: +- name: OPENAI_API_KEY + values: {} # Environment variable reference +- name: ANTHROPIC_API_KEY + values: {} +- name: DATABASE_URL + values: + DATABASE_URL: postgresql://localhost:5432/mydb +- name: CUSTOM_API + values: + API_KEY: your_key_here + BASE_URL: https://api.example.com + TIMEOUT: 30 +``` + ### Default Prompt Support Agentman automatically detects and integrates `prompt.txt` files, providing zero-configuration default prompts for your agents. @@ -426,6 +669,8 @@ This ensures your agent automatically executes the default prompt when the conta ## ๐ŸŽฏ Example Projects +All example projects in the `/examples` directory include both Dockerfile-style `Agentfile` and YAML format `Agentfile.yml` for comparison and learning. You can use either format to build and run the examples. + ### 1. GitHub Profile Manager (with Default Prompt) A comprehensive GitHub profile management agent that automatically loads a default prompt. @@ -433,7 +678,8 @@ A comprehensive GitHub profile management agent that automatically loads a defau **Project Structure:** ``` github-profile-manager/ -โ”œโ”€โ”€ Agentfile +โ”œโ”€โ”€ Agentfile # Dockerfile format +โ”œโ”€โ”€ Agentfile.yml # YAML format (same functionality) โ”œโ”€โ”€ prompt.txt # Default prompt automatically loaded โ””โ”€โ”€ agent/ # Generated files โ”œโ”€โ”€ agent.py @@ -441,6 +687,18 @@ github-profile-manager/ โ””โ”€โ”€ ... ``` +**Build with either format:** +```bash +# Using Dockerfile format +agentman build -f Agentfile . + +# Using YAML format +agentman build -f Agentfile.yml . + +# Auto-detection (picks first available) +agentman build . +``` + **prompt.txt:** ```text I am a GitHub user with the username "yeahdongcn" and I need help updating my GitHub profile information. @@ -512,6 +770,48 @@ ROUTER support_router AGENTS support_agent escalation_agent INSTRUCTION Route based on inquiry complexity and urgency ``` + +### 5. Structured Output Example + +Demonstrates JSONSchema validation for agent outputs with both inline and external schema definitions. + +**Project Structure:** +``` +structured-output-example/ +โ”œโ”€โ”€ Agentfile # Dockerfile format with JSON schema +โ”œโ”€โ”€ Agentfile.yml # YAML format with inline schema +โ”œโ”€โ”€ schemas/ # External schema files +โ”‚ โ”œโ”€โ”€ extraction_schema.yaml +โ”‚ โ””โ”€โ”€ simple_schema.json +โ””โ”€โ”€ agent/ # Generated files +``` + +**Key Features:** +- **Inline JSONSchema**: Define validation schemas directly in YAML format +- **External Schema Files**: Reference separate `.json` or `.yaml` schema files +- **Type Safety**: Validate agent outputs against predefined schemas +- **Both Format Support**: Works with Dockerfile and YAML Agentfiles + +**Example Agent with Output Format:** +```yaml +agents: + - name: sentiment_analyzer + instruction: Analyze sentiment and return structured results + output_format: + type: json_schema + schema: + type: object + properties: + sentiment: + type: string + enum: [positive, negative, neutral] + confidence: + type: number + minimum: 0 + maximum: 1 + required: [sentiment, confidence] +``` + ## ๐Ÿ”ง Advanced Configuration ### Custom Base Images @@ -573,7 +873,7 @@ agentman/ ## ๐Ÿ—๏ธ Building from Source ```bash -git clone https://github.com/yeahdongcn/agentman.git +git clone https://github.com/o3-cloud/agentman.git cd agentman # Install diff --git a/examples/agno-advanced/Agentfile.yml b/examples/agno-advanced/Agentfile.yml new file mode 100644 index 0000000..7df05ac --- /dev/null +++ b/examples/agno-advanced/Agentfile.yml @@ -0,0 +1,48 @@ +apiVersion: v1 +kind: Agent +base: + model: deepseek/deepseek-chat + framework: agno +mcp_servers: +- name: web_search + command: uvx + args: + - mcp-server-duckduckgo +- name: finance + command: uvx + args: + - mcp-server-yfinance +- name: file + command: uvx + args: + - mcp-server-filesystem +agents: +- name: research_coordinator + instruction: You are a research coordinator who plans and manages research projects. + You analyze requirements, break down tasks, and coordinate with specialists. + servers: + - web_search + - file + model: deepseek/deepseek-chat +- name: data_analyst + instruction: You are a financial data analyst specialized in stock analysis, market + trends, and investment research. Provide detailed financial insights and recommendations. + servers: + - finance + - file + model: openai/gpt-4o +- name: content_creator + instruction: You are a content creator who synthesizes research findings into comprehensive + reports, presentations, and summaries. + servers: + - file + model: deepseek/deepseek-chat +secrets: +- name: DEEPSEEK_API_KEY + values: {} +- name: DEEPSEEK_BASE_URL + values: {} +- name: OPENAI_API_KEY + values: {} +- name: OPENAI_BASE_URL + values: {} diff --git a/examples/agno-example/Agentfile.yml b/examples/agno-example/Agentfile.yml new file mode 100644 index 0000000..3c163fe --- /dev/null +++ b/examples/agno-example/Agentfile.yml @@ -0,0 +1,21 @@ +apiVersion: v1 +kind: Agent +base: + model: qwen-plus + framework: agno +mcp_servers: +- name: web_search + command: uvx + args: + - mcp-server-fetch +agents: +- name: assistant + instruction: You are a helpful AI assistant that can search the web and provide + comprehensive answers. + servers: + - web_search +secrets: +- name: OPENAI_API_KEY + value: sk-... +- name: OPENAI_BASE_URL + value: https://dashscope.aliyuncs.com/compatible-mode/v1 diff --git a/examples/agno-ollama/Agentfile.yml b/examples/agno-ollama/Agentfile.yml new file mode 100644 index 0000000..218d65e --- /dev/null +++ b/examples/agno-ollama/Agentfile.yml @@ -0,0 +1,22 @@ +apiVersion: v1 +kind: Agent +base: + model: ollama/llama3.2 + framework: agno +mcp_servers: +- name: web_search + command: uvx + args: + - mcp-server-duckduckgo +agents: +- name: assistant + instruction: You are a helpful AI assistant powered by Ollama that can search the + web and provide comprehensive answers. + servers: + - web_search + model: ollama/llama3.2 +secrets: +- name: OLLAMA_API_KEY + value: your-api-key-here +- name: OLLAMA_BASE_URL + value: http://localhost:11434/v1 diff --git a/examples/agno-team-example/Agentfile.yml b/examples/agno-team-example/Agentfile.yml new file mode 100644 index 0000000..4e7e7c9 --- /dev/null +++ b/examples/agno-team-example/Agentfile.yml @@ -0,0 +1,24 @@ +apiVersion: v1 +kind: Agent +base: + model: qwen-plus + framework: agno +mcp_servers: +- name: web_search +- name: finance +agents: +- name: web_researcher + instruction: You are a web research specialist. Search for information, analyze + sources, and provide comprehensive research findings. + servers: + - web_search +- name: data_analyst + instruction: You are a data analysis expert. Analyze financial data, create reports, + and provide investment insights. + servers: + - finance +secrets: +- name: OPENAI_API_KEY + value: sk-... +- name: OPENAI_BASE_URL + value: https://dashscope.aliyuncs.com/compatible-mode/v1 diff --git a/examples/chain-aliyun/Agentfile.yml b/examples/chain-aliyun/Agentfile.yml new file mode 100644 index 0000000..ba122cc --- /dev/null +++ b/examples/chain-aliyun/Agentfile.yml @@ -0,0 +1,25 @@ +apiVersion: v1 +kind: Agent +base: + model: qwen-plus +mcp_servers: +- name: fetch + command: uvx + args: + - mcp-server-fetch +agents: +- name: url_fetcher + instruction: Given a URL, provide a complete and comprehensive summary + servers: + - fetch +- name: social_media + instruction: Write a 280 character social media post for any given text. Respond + only with the post, never use hashtags. +chains: +- name: post_writer + sequence: + - url_fetcher + - social_media +secrets: +- name: ALIYUN_API_KEY + value: sk-... diff --git a/examples/chain-ollama/Agentfile.yml b/examples/chain-ollama/Agentfile.yml new file mode 100644 index 0000000..5542003 --- /dev/null +++ b/examples/chain-ollama/Agentfile.yml @@ -0,0 +1,27 @@ +apiVersion: v1 +kind: Agent +base: + model: generic.qwen3:latest +mcp_servers: +- name: fetch + command: uvx + args: + - mcp-server-fetch +agents: +- name: url_fetcher + instruction: Given a URL, provide a complete and comprehensive summary + servers: + - fetch +- name: social_media + instruction: Write a 280 character social media post for any given text. Respond + only with the post, never use hashtags. +chains: +- name: post_writer + sequence: + - url_fetcher + - social_media +secrets: +- name: GENERIC + values: + API_KEY: ollama + BASE_URL: http://host.docker.internal:11434/v1 diff --git a/examples/fast-agent-example/Agentfile.yml b/examples/fast-agent-example/Agentfile.yml new file mode 100644 index 0000000..42a24d8 --- /dev/null +++ b/examples/fast-agent-example/Agentfile.yml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Agent +base: + model: anthropic/claude-3-sonnet-20241022 +mcp_servers: +- name: web_search +agents: +- name: assistant + instruction: You are a helpful AI assistant that can search the web and provide + comprehensive answers. + servers: + - web_search +secrets: +- name: ANTHROPIC_API_KEY + values: {} diff --git a/examples/github-maintainer/Agentfile.yml b/examples/github-maintainer/Agentfile.yml new file mode 100644 index 0000000..d1e741e --- /dev/null +++ b/examples/github-maintainer/Agentfile.yml @@ -0,0 +1,104 @@ +apiVersion: v1 +kind: Agent +base: + model: qwen-plus +mcp_servers: +- name: fetch + command: uvx + args: + - mcp-server-fetch +- name: git + command: uvx + args: + - mcp-server-git +- name: filesystem + command: npx + args: + - -y + - '@modelcontextprotocol/server-filesystem' + - /ws +- name: commands + command: npx + args: + - mcp-server-commands +- name: github-mcp-server + command: /server/github-mcp-server + args: + - stdio + env: + GITHUB_PERSONAL_ACCESS_TOKEN: ghp_... +agents: +- name: github-release-checker + instruction: "Given a GitHub repository URL, find the latest **official release**\ + \ of the repository. An official release must meet **all** of the following conditions:\ + \ 1. It MUST be explicitly marked as **\u201CLatest\u201D** on the GitHub Releases\ + \ page. 2. It MUST **not** be marked as a **\u201CPre-release\u201D**. 3. Its\ + \ **tag** or **tag_name** MUST **not** contain pre-release identifiers such as\ + \ `rc`, `alpha`, `beta`, etc. (e.g., tags like `v0.9.1-rc0`, `v1.0.0-beta`, or\ + \ `v2.0.0-alpha` should be considered pre-releases and **ignored**). If a release\ + \ does not satisfy all these conditions, do **not** return it. Instead, continue\ + \ fetching additional releases until you find the most recent release that satisfies\ + \ the criteria. Once you find a valid release, return the **tag** of that release." + servers: + - fetch + - github-mcp-server +- name: github-repository-cloner + instruction: Given a GitHub repository URL and a release tag, clone the repository + by using git clone command and checkout to the specified release tag. You should + also ensure that the repository is cloned to the /ws directory. + servers: + - commands + - git + - filesystem +- name: latest-commit-checker + instruction: Given a GitHub repository local path, check if the latest commit of + the repository matches the specified release tag. If it does, return \"The latest + commit matches the release tag.\" Otherwise, return \"The latest commit does not + match the release tag.\" + servers: + - commands + - git + - filesystem +secrets: +- name: ALIYUN_API_KEY + value: sk-... +dockerfile: +- instruction: COPY + args: + - --from=ghcr.io/github/github-mcp-server + - /server/github-mcp-server + - /server/github-mcp-server +- instruction: COPY + args: + - --from=ghcr.io/github/github-mcp-server + - /etc/ssl/certs/ca-certificates.crt + - /etc/ssl/certs/ca-certificates.crt +- instruction: ENV + args: + - SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt +- instruction: RUN + args: + - apt-get + - update + - '&&' + - apt-get + - install + - -y + - --no-install-recommends + - git + - '&&' + - rm + - -rf + - /var/lib/apt/lists/* +- instruction: RUN + args: + - mkdir + - -p + - /app + - '&&' + - mkdir + - -p + - /ws +- instruction: WORKDIR + args: + - /app diff --git a/examples/github-profile-manager/Agentfile.yml b/examples/github-profile-manager/Agentfile.yml new file mode 100644 index 0000000..1a2e896 --- /dev/null +++ b/examples/github-profile-manager/Agentfile.yml @@ -0,0 +1,81 @@ +apiVersion: v1 +kind: Agent +base: + model: qwen-plus +mcp_servers: +- name: fetch + command: uvx + args: + - mcp-server-fetch +- name: github-mcp-server + command: /server/github-mcp-server + args: + - stdio + env: + GITHUB_PERSONAL_ACCESS_TOKEN: ghp_... +agents: +- name: github-profile-fetcher + instruction: Given a GitHub username, fetch the user's profile information including + their name, bio, location, and public repositories count as basic information. Then + fetch the user's latest activities including their latest commits, issues, and + pull requests. Finally, aggregate all the fetched information into a structured + format and return it. + servers: + - fetch + - github-mcp-server +- name: github-profile-markdown-generator + instruction: "Given a GitHub profile information, generate a Markdown formatted\ + \ profile summary which like the following example: ```markdown ### Hi, I'm Akshay\ + \ \U0001F44B I build foundational Python tools for developers who\ + \ work with data. - \U0001F4BB I'm currently working on [marimo](https://github.com/marimo-team/marimo),\ + \ a new kind of reactive Python notebook. - \U0001F52D I developed [PyMDE](https://github.com/cvxgrp/pymde),\ + \ a PyTorch library for computing custom embeddings of large datasets. - \U0001F5A9\ + \ I'm a maintainer and developer of [CVXPY](https://github.com/cvxpy/cvxpy), a\ + \ widely-used library for mathematical optimization. - \U0001F4DA\ + \ I love writing. I write [blog posts](https://www.debugmind.com/2020/01/04/paths-to-the-future-a-year-at-google-brain/),\ + \ research [papers](https://www.akshayagrawal.com/), and books, including a [book\ + \ on embeddings](https://web.stanford.edu/~boyd/papers/min_dist_emb.html). \ + \ - \U0001F393 I graduated from Stanford University with a PhD, advised\ + \ by [Stephen Boyd](https://web.stanford.edu/~boyd/index.html). All my papers\ + \ are accompanied by open-source software. I'm always open to conversations.\ + \ Reach me via [email](mailto:akshay@marimo.io). ``` The generated Markdown should\ + \ be well-formatted and ready to be used in a GitHub profile README." +- name: github-profile-updater + instruction: Given the generated Markdown profile summary, update the GitHub profile + README with the new content. Ensure that the README is updated in a way that it + reflects the latest information about the user. + servers: + - github-mcp-server +orchestrators: +- name: github-profile-manager + agents: + - github-profile-fetcher + - github-profile-markdown-generator + - github-profile-updater + plan_iterations: 30 + default: true +secrets: +- name: ALIYUN_API_KEY + value: sk-... +dockerfile: +- instruction: COPY + args: + - --from=ghcr.io/github/github-mcp-server + - /server/github-mcp-server + - /server/github-mcp-server +- instruction: COPY + args: + - --from=ghcr.io/github/github-mcp-server + - /etc/ssl/certs/ca-certificates.crt + - /etc/ssl/certs/ca-certificates.crt +- instruction: ENV + args: + - SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt +- instruction: RUN + args: + - mkdir + - -p + - /app +- instruction: WORKDIR + args: + - /app diff --git a/examples/structured-output-example/Agentfile b/examples/structured-output-example/Agentfile new file mode 100644 index 0000000..f8bd6db --- /dev/null +++ b/examples/structured-output-example/Agentfile @@ -0,0 +1,21 @@ +FROM yeahdongcn/agentman-base:latest +MODEL anthropic/claude-3-sonnet +FRAMEWORK fast-agent + +MCP_SERVER fetch +COMMAND uvx +ARGS mcp-server-fetch +TRANSPORT stdio + +AGENT sentiment_analyzer +INSTRUCTION Analyze the sentiment of provided text and return structured results. Classify sentiment as positive, negative, or neutral with confidence scores. +SERVERS fetch +USE_HISTORY false +OUTPUT_FORMAT json_schema {"type":"object","properties":{"text":{"type":"string","description":"The original text analyzed"},"sentiment":{"type":"string","enum":["positive","negative","neutral"],"description":"The detected sentiment"},"confidence":{"type":"number","minimum":0,"maximum":1,"description":"Confidence score for the sentiment classification"},"keywords":{"type":"array","items":{"type":"string"},"description":"Key words that influenced the sentiment"}},"required":["text","sentiment","confidence","keywords"]} + +AGENT file_processor +INSTRUCTION Process files and extract structured information according to the schema. +SERVERS fetch +OUTPUT_FORMAT schema_file ./schemas/simple_schema.json + +CMD ["python", "agent.py"] \ No newline at end of file diff --git a/examples/structured-output-example/Agentfile.yml b/examples/structured-output-example/Agentfile.yml new file mode 100644 index 0000000..ccd3368 --- /dev/null +++ b/examples/structured-output-example/Agentfile.yml @@ -0,0 +1,54 @@ +apiVersion: v1 +kind: Agent + +base: + model: gpt-4.1 + framework: fast-agent + +mcp_servers: + - name: fetch + command: uvx + args: [mcp-server-fetch] + transport: stdio + +agents: + - name: sentiment_analyzer + instruction: | + Analyze the sentiment of provided text and return structured results. + Classify sentiment as positive, negative, or neutral with confidence scores. + servers: [fetch] + use_history: false + output_format: + type: json_schema + schema: + type: object + properties: + text: + type: string + description: The original text analyzed + sentiment: + type: string + enum: [positive, negative, neutral] + description: The detected sentiment + confidence: + type: number + minimum: 0 + maximum: 1 + description: Confidence score for the sentiment classification + keywords: + type: array + items: + type: string + description: Key words that influenced the sentiment + required: [text, sentiment, confidence, keywords] + + - name: data_extractor + instruction: | + Extract structured data from documents and web pages. + Return results according to the predefined schema. + servers: [fetch] + output_format: + type: schema_file + file: ./schemas/extraction_schema.yaml + +command: [python, agent.py] \ No newline at end of file diff --git a/examples/structured-output-example/README.md b/examples/structured-output-example/README.md new file mode 100644 index 0000000..d482d59 --- /dev/null +++ b/examples/structured-output-example/README.md @@ -0,0 +1,110 @@ +# Structured Output Example + +This example demonstrates the new structured data output support in Agentman using JSONSchema validation. + +## Features + +- **Inline JSONSchema as YAML**: Define validation schemas directly in your Agentfile +- **External Schema Files**: Reference separate `.json` or `.yaml` schema files +- **Both Format Support**: Works with both Dockerfile-style and YAML Agentfiles + +## Examples + +### YAML Format (`Agentfile.yml`) + +```yaml +agents: + - name: sentiment_analyzer + instruction: Analyze sentiment and return structured results + output_format: + type: json_schema + schema: + type: object + properties: + sentiment: + type: string + enum: [positive, negative, neutral] + confidence: + type: number + minimum: 0 + maximum: 1 + required: [sentiment, confidence] + + - name: data_extractor + instruction: Extract data from documents + output_format: + type: schema_file + file: ./schemas/extraction_schema.yaml +``` + +### Dockerfile Format (`Agentfile`) + +```dockerfile +AGENT sentiment_analyzer +INSTRUCTION Analyze sentiment and return structured results +OUTPUT_FORMAT json_schema {"type":"object","properties":{"sentiment":{"type":"string","enum":["positive","negative","neutral"]}}} + +AGENT file_processor +INSTRUCTION Process files according to schema +OUTPUT_FORMAT schema_file ./schemas/simple_schema.json +``` + +## Schema Files + +### YAML Schema (`schemas/extraction_schema.yaml`) +```yaml +type: object +properties: + title: + type: string + content: + type: object + properties: + paragraphs: + type: array + items: + type: string +required: [title, content] +``` + +### JSON Schema (`schemas/simple_schema.json`) +```json +{ + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["success", "error"] + }, + "message": { + "type": "string" + } + }, + "required": ["status", "message"] +} +``` + +## Usage + +1. **Build the agent:** + ```bash + agentman build . + ``` + +2. **Run with YAML format:** + ```bash + agentman run --from-agentfile -f Agentfile.yml . + ``` + +3. **Run with Dockerfile format:** + ```bash + agentman run --from-agentfile -f Agentfile . + ``` + +## Benefits + +- **Type Safety**: Validate agent outputs against predefined schemas +- **Documentation**: Schemas serve as output documentation +- **IDE Support**: JSON Schema provides autocomplete and validation in IDEs +- **Flexibility**: Support both inline and external schema definitions +- **Standards**: Uses standard JSONSchema specification \ No newline at end of file diff --git a/examples/structured-output-example/schemas/extraction_schema.yaml b/examples/structured-output-example/schemas/extraction_schema.yaml new file mode 100644 index 0000000..ba21c71 --- /dev/null +++ b/examples/structured-output-example/schemas/extraction_schema.yaml @@ -0,0 +1,53 @@ +type: object +properties: + source_url: + type: string + format: uri + description: The URL of the document or page analyzed + title: + type: string + description: The title of the document + content: + type: object + properties: + headings: + type: array + items: + type: object + properties: + level: + type: integer + minimum: 1 + maximum: 6 + text: + type: string + description: Document headings with their levels + paragraphs: + type: array + items: + type: string + description: Main content paragraphs + links: + type: array + items: + type: object + properties: + url: + type: string + format: uri + text: + type: string + description: Links found in the document + metadata: + type: object + properties: + author: + type: string + published_date: + type: string + format: date + word_count: + type: integer + minimum: 0 + description: Document metadata +required: [source_url, title, content] \ No newline at end of file diff --git a/examples/structured-output-example/schemas/simple_schema.json b/examples/structured-output-example/schemas/simple_schema.json new file mode 100644 index 0000000..60ca95d --- /dev/null +++ b/examples/structured-output-example/schemas/simple_schema.json @@ -0,0 +1,19 @@ +{ + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["success", "error"], + "description": "Operation status" + }, + "message": { + "type": "string", + "description": "Status message" + }, + "data": { + "type": "object", + "description": "Result data" + } + }, + "required": ["status", "message"] +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 7506eb6..47ddd5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -129,7 +129,8 @@ disable = [ "too-many-statements", "too-many-instance-attributes", "too-few-public-methods", - "unused-argument" + "unused-argument", + "too-many-locals" ] [tool.pylint.format] diff --git a/src/agentman/agent_builder.py b/src/agentman/agent_builder.py index 2a9fe94..2e81258 100644 --- a/src/agentman/agent_builder.py +++ b/src/agentman/agent_builder.py @@ -1,13 +1,13 @@ """Agent builder module for generating files from Agentfile configuration.""" import json +import shutil import subprocess from pathlib import Path -import yaml - from agentman.agentfile_parser import AgentfileConfig, AgentfileParser from agentman.frameworks import AgnoFramework, FastAgentFramework +from agentman.yaml_parser import AgentfileYamlParser, parse_agentfile class AgentBuilder: @@ -40,8 +40,8 @@ def _get_framework_handler(self): """Get the appropriate framework handler based on configuration.""" if self.config.framework == "agno": return AgnoFramework(self.config, self._output_dir, self.source_dir) - else: - return FastAgentFramework(self.config, self._output_dir, self.source_dir) + + return FastAgentFramework(self.config, self._output_dir, self.source_dir) def build_all(self): """Build all generated files.""" @@ -61,8 +61,6 @@ def _ensure_output_dir(self): def _copy_prompt_file(self): """Copy prompt.txt to output directory if it exists.""" if self.has_prompt_file: - import shutil - dest_path = self.output_dir / "prompt.txt" shutil.copy2(self.prompt_file_path, dest_path) @@ -230,13 +228,19 @@ def _validate_output(self): except (subprocess.CalledProcessError, FileNotFoundError) as e: # If fast-agent is not available or validation fails, just warn but don't fail print(f"โš ๏ธ Validation skipped: {e}") - pass -def build_from_agentfile(agentfile_path: str, output_dir: str = "output") -> None: +def build_from_agentfile(agentfile_path: str, output_dir: str = "output", format_hint: str = None) -> None: """Build agent files from an Agentfile.""" - parser = AgentfileParser() - config = parser.parse_file(agentfile_path) + if format_hint == "yaml": + parser = AgentfileYamlParser() + config = parser.parse_file(agentfile_path) + elif format_hint == "dockerfile": + parser = AgentfileParser() + config = parser.parse_file(agentfile_path) + else: + # Auto-detect format + config = parse_agentfile(agentfile_path) # Extract source directory from agentfile path source_dir = Path(agentfile_path).parent diff --git a/src/agentman/agentfile_parser.py b/src/agentman/agentfile_parser.py index c0af07a..0e5bfd3 100644 --- a/src/agentman/agentfile_parser.py +++ b/src/agentman/agentfile_parser.py @@ -1,9 +1,44 @@ """Agentfile parser module for parsing Agentfile configurations.""" import json +import os +import re from dataclasses import dataclass, field from typing import Any, Dict, List, Optional, Union +import yaml + + +def expand_env_vars(value: str) -> str: + """ + Expand environment variables in a string. + + Supports both ${VAR} and $VAR syntax. + If environment variable is not found, returns the original placeholder. + + Args: + value: String that may contain environment variable references + + Returns: + String with environment variables expanded + """ + if not isinstance(value, str): + return value + + # Pattern to match ${VAR} or $VAR (where VAR is alphanumeric + underscore) + pattern = r'\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)' + + def replace_var(match): + # Get the variable name from either group + var_name = match.group(1) or match.group(2) + env_value = os.environ.get(var_name) + if env_value is not None: + return env_value + # Return the original placeholder if env var not found + return match.group(0) + + return re.sub(pattern, replace_var, value) + @dataclass class MCPServer: @@ -32,6 +67,15 @@ def to_config_dict(self) -> Dict[str, Any]: return config +@dataclass +class OutputFormat: + """Represents output format configuration for an agent.""" + + type: str # "json_schema" or "schema_file" + schema: Optional[Dict[str, Any]] = None # For inline JSON Schema as YAML + file: Optional[str] = None # For external schema file reference + + @dataclass class Agent: """Represents an agent configuration.""" @@ -43,8 +87,9 @@ class Agent: use_history: bool = True human_input: bool = False default: bool = False + output_format: Optional[OutputFormat] = None - def to_decorator_string(self, default_model: Optional[str] = None) -> str: + def to_decorator_string(self, default_model: Optional[str] = None, base_path: Optional[str] = None) -> str: """Generate the @fast.agent decorator string.""" params = [f'name="{self.name}"', f'instruction="""{self.instruction}"""'] @@ -64,8 +109,70 @@ def to_decorator_string(self, default_model: Optional[str] = None) -> str: if self.default: params.append("default=True") + # Add response_format if output_format is specified + if self.output_format: + request_params = self._generate_request_params(base_path) + if request_params: + params.append(f"request_params={request_params}") + return "@fast.agent(\n " + ",\n ".join(params) + "\n)" + def _generate_request_params(self, base_path: Optional[str] = None) -> Optional[str]: + """Generate RequestParams with response_format from output_format.""" + if not self.output_format: + return None + + if self.output_format.type == "json_schema" and self.output_format.schema: + # Convert JSON Schema to OpenAI response_format structure + schema = self.output_format.schema + model_name = self._get_model_name_from_schema(schema) + + response_format = {"type": "json_schema", "json_schema": {"name": model_name, "schema": schema}} + + return f"RequestParams(response_format={response_format})" + + if self.output_format.type == "schema_file" and self.output_format.file: + # Load and convert external schema file + return self._generate_request_params_from_file(base_path) + + return None + + def _generate_request_params_from_file(self, base_path: Optional[str] = None) -> str: + """Generate RequestParams by loading schema from external file.""" + + file_path = self.output_format.file + + # Resolve relative paths relative to the Agentfile location + if not os.path.isabs(file_path) and base_path: + file_path = os.path.join(base_path, file_path) + + try: + with open(file_path, 'r', encoding='utf-8') as f: + if file_path.endswith('.json'): + schema = json.load(f) + elif file_path.endswith(('.yaml', '.yml')): + schema = yaml.safe_load(f) + else: + return f"# Error: Unsupported schema file format: {file_path}" + + model_name = self._get_model_name_from_schema(schema) + response_format = {"type": "json_schema", "json_schema": {"name": model_name, "schema": schema}} + + return f"RequestParams(response_format={response_format})" + + except (FileNotFoundError, json.JSONDecodeError, yaml.YAMLError) as e: + return f"# Error loading schema file {file_path}: {e}" + + def _get_model_name_from_schema(self, schema: Dict[str, Any]) -> str: + """Generate a model name from the agent name or schema title.""" + if isinstance(schema, dict) and "title" in schema: + return schema["title"] + + # Convert agent name to PascalCase for model name + words = self.name.replace("-", "_").replace(" ", "_").split("_") + model_name = "".join(word.capitalize() for word in words if word) + return f"{model_name}Model" + @dataclass class Router: @@ -232,13 +339,17 @@ class AgentfileConfig: class AgentfileParser: """Parser for Agentfile format.""" - def __init__(self): + def __init__(self, base_path: Optional[str] = None): self.config = AgentfileConfig() self.current_context = None self.current_item = None + self.base_path = base_path def parse_file(self, filepath: str) -> AgentfileConfig: """Parse an Agentfile and return the configuration.""" + # Store the directory containing the Agentfile for resolving relative paths + self.base_path = os.path.dirname(os.path.abspath(filepath)) + with open(filepath, 'r', encoding='utf-8') as f: content = f.read() return self.parse_content(content) @@ -378,6 +489,7 @@ def _parse_line(self, line: str): "API_KEY", "BASE_URL", "DEFAULT", + "OUTPUT_FORMAT", ]: self._handle_sub_instruction(instruction, parts) # Handle ENV - could be Dockerfile instruction or sub-instruction @@ -516,7 +628,8 @@ def _handle_secret(self, parts: List[str]): # Check if it's an inline value: SECRET KEY value if len(parts) >= 3: value = ' '.join(parts[2:]) # Join all remaining parts as the value - secret = SecretValue(name=secret_name, value=self._unquote(value)) + expanded_value = expand_env_vars(self._unquote(value)) + secret = SecretValue(name=secret_name, value=expanded_value) self.config.secrets.append(secret) self.current_context = None # Check if it's a context (no value, will be populated with sub-instructions) @@ -565,7 +678,8 @@ def _handle_secret_sub_instruction(self, instruction: str, parts: List[str]): if len(parts) >= 2: key = instruction.upper() value = ' '.join(parts[1:]) - secret_context.values[key] = self._unquote(value) + expanded_value = expand_env_vars(self._unquote(value)) + secret_context.values[key] = expanded_value else: raise ValueError("SECRET context requires KEY VALUE format") @@ -680,14 +794,16 @@ def _handle_server_sub_instruction(self, instruction: str, parts: List[str]): key, value = env_part.split('=', 1) # Split only on first = key = self._unquote(key) value = self._unquote(value) - server.env[key] = value + expanded_value = expand_env_vars(value) + server.env[key] = expanded_value else: raise ValueError("ENV requires KEY VALUE or KEY=VALUE") elif len(parts) >= 3: # Handle KEY VALUE format key = self._unquote(parts[1]) value = self._unquote(' '.join(parts[2:])) # Join remaining parts as value - server.env[key] = value + expanded_value = expand_env_vars(value) + server.env[key] = expanded_value else: raise ValueError("ENV requires KEY VALUE or KEY=VALUE") @@ -719,6 +835,35 @@ def _handle_agent_sub_instruction(self, instruction: str, parts: List[str]): if len(parts) < 2: raise ValueError("DEFAULT requires true/false") agent.default = self._unquote(parts[1]).lower() in ['true', '1', 'yes'] + elif instruction == "OUTPUT_FORMAT": + if len(parts) < 2: + raise ValueError("OUTPUT_FORMAT requires a format type") + format_type = self._unquote(parts[1]) + if format_type == "json_schema": + if len(parts) < 3: + raise ValueError("OUTPUT_FORMAT json_schema requires a schema definition or file reference") + schema_value = self._unquote(' '.join(parts[2:])) + # Try to parse as inline YAML/JSON schema + try: + schema_dict = yaml.safe_load(schema_value) + agent.output_format = OutputFormat(type="json_schema", schema=schema_dict) + except (ImportError, yaml.YAMLError) as err: + # Fallback: treat as file reference if it looks like a path + if schema_value.endswith(('.json', '.yaml', '.yml')): + agent.output_format = OutputFormat(type="schema_file", file=schema_value) + else: + raise ValueError( + "OUTPUT_FORMAT json_schema requires valid YAML/JSON schema or file path" + ) from err + elif format_type == "schema_file": + if len(parts) < 3: + raise ValueError("OUTPUT_FORMAT schema_file requires a file path") + file_path = self._unquote(parts[2]) + if not file_path.endswith(('.json', '.yaml', '.yml')): + raise ValueError("OUTPUT_FORMAT schema_file must reference a .json, .yaml, or .yml file") + agent.output_format = OutputFormat(type="schema_file", file=file_path) + else: + raise ValueError(f"Invalid OUTPUT_FORMAT type: {format_type}. Supported: json_schema, schema_file") def _handle_router_sub_instruction(self, instruction: str, parts: List[str]): """Handle sub-instructions for ROUTER context.""" diff --git a/src/agentman/agentfile_schema.py b/src/agentman/agentfile_schema.py new file mode 100644 index 0000000..8f63cda --- /dev/null +++ b/src/agentman/agentfile_schema.py @@ -0,0 +1,305 @@ +"""JSON Schema for validating YAML Agentfile configurations.""" + +import json +from typing import Any, Dict + +try: + import jsonschema +except ImportError: + jsonschema = None + +# JSON Schema for YAML Agentfile format +AGENTFILE_YAML_SCHEMA: Dict[str, Any] = { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["apiVersion", "kind"], + "properties": { + "apiVersion": {"type": "string", "const": "v1", "description": "API version, currently only 'v1' is supported"}, + "kind": { + "type": "string", + "const": "Agent", + "description": "Resource kind, currently only 'Agent' is supported", + }, + "base": { + "type": "object", + "properties": { + "image": { + "type": "string", + "description": "Base Docker image", + "default": "ghcr.io/o3-cloud/pai/base:latest", + }, + "model": { + "type": "string", + "description": "Default model to use for agents", + "examples": ["gpt-4", "anthropic/claude-3-sonnet-20241022"], + }, + "framework": { + "type": "string", + "enum": ["fast-agent", "agno"], + "description": "Framework to use for agent development", + "default": "fast-agent", + }, + }, + "additionalProperties": False, + }, + "mcp_servers": { + "type": "array", + "description": "List of MCP (Model Context Protocol) servers", + "items": { + "type": "object", + "required": ["name"], + "properties": { + "name": {"type": "string", "description": "Unique name for the MCP server"}, + "command": {"type": "string", "description": "Command to run the MCP server"}, + "args": { + "type": "array", + "items": {"type": "string"}, + "description": "Arguments to pass to the command", + }, + "transport": { + "type": "string", + "enum": ["stdio", "sse", "http"], + "default": "stdio", + "description": "Transport method for the MCP server", + }, + "url": {"type": "string", "description": "URL for HTTP/SSE transport"}, + "env": { + "type": "object", + "patternProperties": {"^[A-Z_][A-Z0-9_]*$": {"type": "string"}}, + "additionalProperties": False, + "description": "Environment variables for the MCP server", + }, + }, + "additionalProperties": False, + }, + }, + "agents": { + "type": "array", + "description": "List of agents", + "items": { + "type": "object", + "required": ["name"], + "properties": { + "name": {"type": "string", "description": "Name of the agent"}, + "instruction": { + "type": "string", + "description": "Instructions for the agent", + "default": "You are a helpful agent.", + }, + "servers": { + "type": "array", + "items": {"type": "string"}, + "description": "List of MCP server names this agent can use", + }, + "model": {"type": "string", "description": "Model to use for this agent (overrides base model)"}, + "use_history": { + "type": "boolean", + "default": True, + "description": "Whether the agent should use conversation history", + }, + "human_input": { + "type": "boolean", + "default": False, + "description": "Whether the agent should prompt for human input", + }, + "default": { + "type": "boolean", + "default": False, + "description": "Whether this is the default agent", + }, + "output_format": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["json_schema"], + "description": "Format type for output validation", + }, + "schema": {"type": "object", "description": "Inline JSON Schema as YAML object"}, + }, + "required": ["type", "schema"], + "additionalProperties": False, + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["schema_file"], + "description": "Reference to external schema file", + }, + "file": { + "type": "string", + "description": "Path to external schema file (.json or .yaml/.yml)", + }, + }, + "required": ["type", "file"], + "additionalProperties": False, + }, + ], + "description": "Output format specification for structured data validation", + }, + }, + "additionalProperties": False, + }, + }, + "command": { + "type": "array", + "items": {"type": "string"}, + "description": "Default command to run in the container", + "default": ["python", "agent.py"], + }, + "secrets": { + "type": "array", + "description": "List of secrets the agent needs", + "items": { + "oneOf": [ + {"type": "string", "description": "Simple secret reference"}, + { + "type": "object", + "required": ["name"], + "properties": { + "name": {"type": "string", "description": "Name of the secret"}, + "value": {"type": "string", "description": "Inline secret value"}, + "values": { + "type": "object", + "patternProperties": {"^[A-Z_][A-Z0-9_]*$": {"type": "string"}}, + "additionalProperties": False, + "description": "Multiple key-value pairs for secret context", + }, + }, + "additionalProperties": False, + "not": {"allOf": [{"required": ["value"]}, {"required": ["values"]}]}, + }, + ] + }, + }, + "expose": { + "type": "array", + "items": {"type": "integer", "minimum": 1, "maximum": 65535}, + "description": "List of ports to expose", + }, + "dockerfile": { + "type": "array", + "description": "Additional Dockerfile instructions", + "items": { + "type": "object", + "required": ["instruction", "args"], + "properties": { + "instruction": {"type": "string", "description": "Dockerfile instruction (e.g., RUN, COPY, ENV)"}, + "args": { + "type": "array", + "items": {"type": "string"}, + "description": "Arguments for the instruction", + }, + }, + "additionalProperties": False, + }, + }, + }, + "additionalProperties": False, +} + + +def validate_yaml_agentfile(data: Dict[str, Any]) -> bool: + """Validate YAML Agentfile data against the schema.""" + if jsonschema is None: + # If jsonschema is not available, skip validation + return True + + try: + jsonschema.validate(data, AGENTFILE_YAML_SCHEMA) + return True + except jsonschema.exceptions.ValidationError: + return False + + +def get_schema_as_json() -> str: + """Get the schema as a JSON string.""" + return json.dumps(AGENTFILE_YAML_SCHEMA, indent=2) + + +def get_example_yaml() -> str: + """Get an example YAML Agentfile.""" + return """apiVersion: v1 +kind: Agent + +base: + image: ghcr.io/o3-cloud/pai/base:latest + model: gpt-4.1 + framework: fast-agent + +mcp_servers: + - name: gmail + command: npx + args: [-y, "@gongrzhe/server-gmail-autoauth-mcp"] + transport: stdio + + - name: fetch + command: uvx + args: [mcp-server-fetch] + transport: stdio + +agents: + - name: gmail_actions + instruction: | + You are a productivity assistant with access to my Gmail inbox. + Using my personal context, perform the following tasks: + 1. Only analyze and classify all emails currently in my inbox. + 2. Assign appropriate labels to each email based on inferred categories. + 3. Archive each email to keep my inbox clean. + servers: [gmail, fetch] + use_history: true + human_input: false + default: true + output_format: + type: json_schema + schema: + type: object + properties: + summary: + type: string + description: Brief summary of actions taken + emails_processed: + type: integer + description: Number of emails processed + labels_applied: + type: array + items: + type: object + properties: + email_subject: + type: string + label: + type: string + reason: + type: string + required: [summary, emails_processed, labels_applied] + + - name: data_analyzer + instruction: Analyze data and generate structured reports + servers: [fetch] + output_format: + type: schema_file + file: ./schemas/analysis_output.yaml + +command: [python, agent.py, -p, prompt.txt, --agent, gmail_actions] + +secrets: + - GMAIL_API_KEY + - name: OPENAI_CONFIG + values: + API_KEY: your-openai-api-key + BASE_URL: https://api.openai.com/v1 + +expose: + - 8080 + +dockerfile: + - instruction: RUN + args: [apt-get, update, &&, apt-get, install, -y, curl] + - instruction: ENV + args: [PYTHONPATH=/app] +""" diff --git a/src/agentman/cli.py b/src/agentman/cli.py index d6b0878..e8175db 100644 --- a/src/agentman/cli.py +++ b/src/agentman/cli.py @@ -8,6 +8,7 @@ from agentman.agent_builder import build_from_agentfile from agentman.common import perror +from agentman.converter import convert_agentfile, validate_agentfile from agentman.version import print_version @@ -135,8 +136,15 @@ def build_cli(args): else: output_dir = context_path / "agent" + # Determine format hint + format_hint = None + if hasattr(args, 'from_yaml') and args.from_yaml: + format_hint = "yaml" + elif hasattr(args, 'format') and args.format: + format_hint = args.format + try: - build_from_agentfile(str(agentfile_path), str(output_dir)) + build_from_agentfile(str(agentfile_path), str(output_dir), format_hint) if args.build_docker: print("\n๐Ÿณ Building Docker image...") @@ -158,6 +166,14 @@ def build_parser(subparsers): parser.add_argument( "--build-docker", action="store_true", help="Also build the Docker image after generating files" ) + parser.add_argument( + "--format", + choices=["dockerfile", "yaml"], + help="Explicitly specify the Agentfile format (auto-detected by default)", + ) + parser.add_argument( + "--from-yaml", action="store_true", help="Build from YAML Agentfile format (same as --format yaml)" + ) parser.add_argument("path", nargs="?", default=".", help="Build context (directory or URL)") parser.usage = "agentman build [OPTIONS] PATH | URL | -" runtime_options(parser, "build") @@ -184,9 +200,16 @@ def run_cli(args): else: output_dir = context_path / "agent" + # Determine format hint + format_hint = None + if hasattr(args, 'from_yaml') and args.from_yaml: + format_hint = "yaml" + elif hasattr(args, 'format') and args.format: + format_hint = args.format + try: print("๐Ÿ”จ Building agent files...") - build_from_agentfile(str(agentfile_path), str(output_dir)) + build_from_agentfile(str(agentfile_path), str(output_dir), format_hint) print("\n๐Ÿณ Building Docker image...") docker_cmd = ["docker", "build", "-t", args.tag, str(output_dir)] @@ -269,25 +292,31 @@ def run_parser(subparsers): parser = subparsers.add_parser("run", help="Create and run a new container from an agent") parser.add_argument("-f", "--file", default="Agentfile", help="Name of the Agentfile (when building from source)") parser.add_argument( - "-o", "--output", help="Output directory for generated files " "(default: agent, when building from source)" + "-o", "--output", help="Output directory for generated files (default: agent, when building from source)" ) parser.add_argument("-t", "--tag", default="agent:latest", help="Name and optionally a tag for the Docker image") parser.add_argument( "--from-agentfile", action="store_true", - help="Build from Agentfile and then run " "(default is to run existing image)", + help="Build from Agentfile and then run (default is to run existing image)", ) - parser.add_argument("--path", default=".", help="Build context (directory or URL) " "when building from Agentfile") - parser.add_argument("-i", "--interactive", action="store_true", help="Run container interactively") parser.add_argument( - "--rm", dest="remove", action="store_true", help="Automatically remove the container when it exits" + "--format", + choices=["dockerfile", "yaml"], + help="Explicitly specify the Agentfile format (auto-detected by default)", + ) + parser.add_argument( + "--from-yaml", action="store_true", help="Build from YAML Agentfile format (same as --format yaml)" ) + parser.add_argument("--path", default=".", help="Build context (directory or URL) when building from Agentfile") + parser.add_argument("-i", "--interactive", action="store_true", help="Run container interactively") parser.add_argument( - "-p", "--port", action="append", help="Publish container port(s) to the host " "(can be used multiple times)" + "--rm", dest="remove", action="store_true", help="Automatically remove the container when it exits" ) parser.add_argument( - "-e", "--env", action="append", help="Set environment variables " "(can be used multiple times)" + "-p", "--port", action="append", help="Publish container port(s) to the host (can be used multiple times)" ) + parser.add_argument("-e", "--env", action="append", help="Set environment variables (can be used multiple times)") parser.add_argument("-v", "--volume", action="append", help="Bind mount volumes (can be used multiple times)") parser.add_argument("command", nargs="*", help="Command to run in the container (overrides default)") runtime_options(parser, "run") @@ -300,6 +329,42 @@ def version_parser(subparsers): parser.set_defaults(func=print_version) +def convert_cli(args): + """Convert between Agentfile formats.""" + try: + target_format = args.format if args.format else "auto" + convert_agentfile(args.input, args.output, target_format) + except (FileNotFoundError, ValueError) as e: + perror(f"Conversion failed: {e}") + sys.exit(1) + + +def convert_parser(subparsers): + """Configure the convert subcommand parser.""" + parser = subparsers.add_parser("convert", help="Convert between Agentfile formats") + parser.add_argument("input", help="Input Agentfile path") + parser.add_argument("output", help="Output Agentfile path") + parser.add_argument( + "--format", + choices=["yaml", "dockerfile"], + help="Target format (auto-detected by default based on output extension)", + ) + parser.set_defaults(func=convert_cli) + + +def validate_cli(args): + """Validate an Agentfile.""" + if not validate_agentfile(args.file): + sys.exit(1) + + +def validate_parser(subparsers): + """Configure the validate subcommand parser.""" + parser = subparsers.add_parser("validate", help="Validate an Agentfile") + parser.add_argument("file", help="Agentfile path to validate") + parser.set_defaults(func=validate_cli) + + def help_cli(args): """Handle the help command by raising HelpException.""" raise HelpException() @@ -318,6 +383,8 @@ def configure_subcommands(parser): subparsers.required = False build_parser(subparsers) run_parser(subparsers) + convert_parser(subparsers) + validate_parser(subparsers) help_parser(subparsers) version_parser(subparsers) diff --git a/src/agentman/converter.py b/src/agentman/converter.py new file mode 100644 index 0000000..644db08 --- /dev/null +++ b/src/agentman/converter.py @@ -0,0 +1,396 @@ +"""Conversion utilities for Agentfile formats.""" + +import json +from pathlib import Path +from typing import Any, Dict + +import yaml + +from agentman.agentfile_parser import ( + AgentfileConfig, + AgentfileParser, + SecretContext, + SecretValue, +) +from agentman.yaml_parser import AgentfileYamlParser, detect_yaml_format, parse_agentfile + + +def dockerfile_to_yaml(dockerfile_path: str, yaml_path: str) -> None: + """Convert a Dockerfile-format Agentfile to YAML format.""" + # Parse the Dockerfile format + parser = AgentfileParser() + config = parser.parse_file(dockerfile_path) + + # Convert to YAML format + yaml_data = config_to_yaml_dict(config) + + # Write to YAML file + with open(yaml_path, 'w', encoding='utf-8') as f: + yaml.dump(yaml_data, f, default_flow_style=False, sort_keys=False, indent=2) + + +def yaml_to_dockerfile(yaml_path: str, dockerfile_path: str) -> None: + """Convert a YAML-format Agentfile to Dockerfile format.""" + # Parse the YAML format + parser = AgentfileYamlParser() + config = parser.parse_file(yaml_path) + + # Convert to Dockerfile format + dockerfile_content = config_to_dockerfile_content(config) + + # Write to Dockerfile format + with open(dockerfile_path, 'w', encoding='utf-8') as f: + f.write(dockerfile_content) + + +def config_to_yaml_dict(config: AgentfileConfig) -> Dict[str, Any]: + """Convert AgentfileConfig to YAML dictionary.""" + yaml_data = {"apiVersion": "v1", "kind": "Agent"} + + # Base configuration + base_config = {} + if config.base_image != "yeahdongcn/agentman-base:latest": + base_config["image"] = config.base_image + if config.default_model: + base_config["model"] = config.default_model + if config.framework != "fast-agent": + base_config["framework"] = config.framework + + if base_config: + yaml_data["base"] = base_config + + # MCP servers + if config.servers: + mcp_servers = [] + for server in config.servers.values(): + server_dict = {"name": server.name} + if server.command: + server_dict["command"] = server.command + if server.args: + server_dict["args"] = server.args + if server.transport != "stdio": + server_dict["transport"] = server.transport + if server.url: + server_dict["url"] = server.url + if server.env: + server_dict["env"] = server.env + mcp_servers.append(server_dict) + yaml_data["mcp_servers"] = mcp_servers + + # Agent configuration + if config.agents: + agents_list = [] + for agent in config.agents.values(): + agent_dict = {"name": agent.name} + if agent.instruction != "You are a helpful agent.": + agent_dict["instruction"] = agent.instruction + if agent.servers: + agent_dict["servers"] = agent.servers + if agent.model: + agent_dict["model"] = agent.model + if not agent.use_history: + agent_dict["use_history"] = agent.use_history + if agent.human_input: + agent_dict["human_input"] = agent.human_input + if agent.default: + agent_dict["default"] = agent.default + agents_list.append(agent_dict) + + yaml_data["agents"] = agents_list + + # Routers + if config.routers: + routers_list = [] + for router in config.routers.values(): + router_dict = {"name": router.name} + if router.agents: + router_dict["agents"] = router.agents + if router.model: + router_dict["model"] = router.model + if router.instruction: + router_dict["instruction"] = router.instruction + if router.default: + router_dict["default"] = router.default + routers_list.append(router_dict) + yaml_data["routers"] = routers_list + + # Chains + if config.chains: + chains_list = [] + for chain in config.chains.values(): + chain_dict = {"name": chain.name} + if chain.sequence: + chain_dict["sequence"] = chain.sequence + if chain.instruction: + chain_dict["instruction"] = chain.instruction + if chain.cumulative: + chain_dict["cumulative"] = chain.cumulative + if not chain.continue_with_final: + chain_dict["continue_with_final"] = chain.continue_with_final + if chain.default: + chain_dict["default"] = chain.default + chains_list.append(chain_dict) + yaml_data["chains"] = chains_list + + # Orchestrators + if config.orchestrators: + orchestrators_list = [] + for orchestrator in config.orchestrators.values(): + orchestrator_dict = {"name": orchestrator.name} + if orchestrator.agents: + orchestrator_dict["agents"] = orchestrator.agents + if orchestrator.model: + orchestrator_dict["model"] = orchestrator.model + if orchestrator.instruction: + orchestrator_dict["instruction"] = orchestrator.instruction + if orchestrator.plan_type != "full": + orchestrator_dict["plan_type"] = orchestrator.plan_type + if orchestrator.plan_iterations != 5: + orchestrator_dict["plan_iterations"] = orchestrator.plan_iterations + if orchestrator.human_input: + orchestrator_dict["human_input"] = orchestrator.human_input + if orchestrator.default: + orchestrator_dict["default"] = orchestrator.default + orchestrators_list.append(orchestrator_dict) + yaml_data["orchestrators"] = orchestrators_list + + # Command + if config.cmd != ["python", "agent.py"]: + yaml_data["command"] = config.cmd + + # Secrets + if config.secrets: + secrets_list = [] + for secret in config.secrets: + if isinstance(secret, str): + secrets_list.append(secret) + elif isinstance(secret, SecretValue): + secrets_list.append({"name": secret.name, "value": secret.value}) + elif isinstance(secret, SecretContext): + secrets_list.append({"name": secret.name, "values": secret.values}) + yaml_data["secrets"] = secrets_list + + # Expose ports + if config.expose_ports: + yaml_data["expose"] = config.expose_ports + + # Dockerfile instructions + if config.dockerfile_instructions: + dockerfile_list = [] + for instruction in config.dockerfile_instructions: + if instruction.instruction not in ["FROM", "CMD"]: # Skip instructions handled elsewhere + dockerfile_list.append({"instruction": instruction.instruction, "args": instruction.args}) + if dockerfile_list: + yaml_data["dockerfile"] = dockerfile_list + + return yaml_data + + +def config_to_dockerfile_content(config: AgentfileConfig) -> str: + """Convert AgentfileConfig to Dockerfile format content.""" + lines = [] + + # FROM instruction + lines.append(f"FROM {config.base_image}") + + # Framework + if config.framework != "fast-agent": + lines.append(f"FRAMEWORK {config.framework}") + + # Model + if config.default_model: + lines.append(f"MODEL {config.default_model}") + + lines.append("") # Empty line for readability + + # Secrets + for secret in config.secrets: + if isinstance(secret, str): + lines.append(f"SECRET {secret}") + elif isinstance(secret, SecretValue): + lines.append(f"SECRET {secret.name} {secret.value}") + elif isinstance(secret, SecretContext): + lines.append(f"SECRET {secret.name}") + for key, value in secret.values.items(): + lines.append(f"{key} {value}") + + if config.secrets: + lines.append("") # Empty line for readability + + # Servers + for server in config.servers.values(): + lines.append(f"MCP_SERVER {server.name}") + if server.command: + lines.append(f"COMMAND {server.command}") + if server.args: + args_str = " ".join(server.args) + lines.append(f"ARGS {args_str}") + if server.transport != "stdio": + lines.append(f"TRANSPORT {server.transport}") + if server.url: + lines.append(f"URL {server.url}") + for key, value in server.env.items(): + lines.append(f"ENV {key} {value}") + lines.append("") # Empty line for readability + + # Agents + for agent in config.agents.values(): + lines.append(f"AGENT {agent.name}") + if agent.instruction != "You are a helpful agent.": + lines.append(f"INSTRUCTION {agent.instruction}") + if agent.servers: + servers_str = " ".join(agent.servers) + lines.append(f"SERVERS {servers_str}") + if agent.model: + lines.append(f"MODEL {agent.model}") + if not agent.use_history: + lines.append("USE_HISTORY false") + if agent.human_input: + lines.append("HUMAN_INPUT true") + if agent.default: + lines.append("DEFAULT true") + lines.append("") # Empty line for readability + + # Routers + for router in config.routers.values(): + lines.append(f"ROUTER {router.name}") + if router.agents: + agents_str = " ".join(router.agents) + lines.append(f"AGENTS {agents_str}") + if router.model: + lines.append(f"MODEL {router.model}") + if router.instruction: + lines.append(f"INSTRUCTION {router.instruction}") + if router.default: + lines.append("DEFAULT true") + lines.append("") # Empty line for readability + + # Chains + for chain in config.chains.values(): + lines.append(f"CHAIN {chain.name}") + if chain.sequence: + sequence_str = " ".join(chain.sequence) + lines.append(f"SEQUENCE {sequence_str}") + if chain.instruction: + lines.append(f"INSTRUCTION {chain.instruction}") + if chain.cumulative: + lines.append("CUMULATIVE true") + if not chain.continue_with_final: + lines.append("CONTINUE_WITH_FINAL false") + if chain.default: + lines.append("DEFAULT true") + lines.append("") # Empty line for readability + + # Orchestrators + for orchestrator in config.orchestrators.values(): + lines.append(f"ORCHESTRATOR {orchestrator.name}") + if orchestrator.agents: + agents_str = " ".join(orchestrator.agents) + lines.append(f"AGENTS {agents_str}") + if orchestrator.model: + lines.append(f"MODEL {orchestrator.model}") + if orchestrator.instruction: + lines.append(f"INSTRUCTION {orchestrator.instruction}") + if orchestrator.plan_type != "full": + lines.append(f"PLAN_TYPE {orchestrator.plan_type}") + if orchestrator.plan_iterations != 5: + lines.append(f"PLAN_ITERATIONS {orchestrator.plan_iterations}") + if orchestrator.human_input: + lines.append("HUMAN_INPUT true") + if orchestrator.default: + lines.append("DEFAULT true") + lines.append("") # Empty line for readability + + # Dockerfile instructions + for instruction in config.dockerfile_instructions: + if instruction.instruction not in ["FROM", "CMD"]: + lines.append(instruction.to_dockerfile_line()) + + # Expose ports + for port in config.expose_ports: + lines.append(f"EXPOSE {port}") + + # CMD instruction + if config.cmd != ["python", "agent.py"]: + if len(config.cmd) == 1: + lines.append(f"CMD {config.cmd[0]}") + else: + lines.append(f"CMD {json.dumps(config.cmd)}") + + return "\n".join(lines) + "\n" + + +def convert_agentfile(input_path: str, output_path: str, target_format: str = "auto") -> None: + """Convert an Agentfile between formats. + + Args: + input_path: Path to the input Agentfile + output_path: Path to write the converted Agentfile + target_format: Target format ("yaml", "dockerfile", or "auto" to infer from output extension) + """ + input_path = Path(input_path) + output_path = Path(output_path) + + if not input_path.exists(): + raise FileNotFoundError(f"Input file not found: {input_path}") + + # Determine target format + if target_format == "auto": + if output_path.suffix.lower() in ['.yml', '.yaml']: + target_format = "yaml" + else: + target_format = "dockerfile" + + # Determine source format + is_yaml_source = detect_yaml_format(str(input_path)) + + if is_yaml_source and target_format == "yaml": + raise ValueError("Input and output formats are both YAML") + if not is_yaml_source and target_format == "dockerfile": + raise ValueError("Input and output formats are both Dockerfile") + + # Convert based on source and target formats + if is_yaml_source and target_format == "dockerfile": + yaml_to_dockerfile(str(input_path), str(output_path)) + elif not is_yaml_source and target_format == "yaml": + dockerfile_to_yaml(str(input_path), str(output_path)) + else: + raise ValueError(f"Unsupported conversion: {is_yaml_source} -> {target_format}") + + print(f"โœ… Converted {input_path} to {output_path} ({target_format} format)") + + +def validate_agentfile(filepath: str) -> bool: + """Validate an Agentfile in either format. + + Args: + filepath: Path to the Agentfile to validate + + Returns: + True if valid, False otherwise + """ + try: + config = parse_agentfile(filepath) + + # Basic validation + if not config.base_image: + print("โŒ Validation failed: Missing base image") + return False + + if not config.agents: + print("โŒ Validation failed: No agents defined") + return False + + # Check that all agent servers are defined + for agent in config.agents.values(): + for server_name in agent.servers: + if server_name not in config.servers: + print(f"โŒ Validation failed: Agent '{agent.name}' references undefined server '{server_name}'") + return False + + print("โœ… Agentfile is valid") + return True + + except (FileNotFoundError, ValueError, yaml.YAMLError) as e: + print(f"โŒ Validation failed: {e}") + return False diff --git a/src/agentman/frameworks/__init__.py b/src/agentman/frameworks/__init__.py index ba30373..5bbd8d8 100644 --- a/src/agentman/frameworks/__init__.py +++ b/src/agentman/frameworks/__init__.py @@ -1,7 +1,7 @@ """Framework support for AgentMan.""" -from .base import BaseFramework from .agno import AgnoFramework +from .base import BaseFramework from .fast_agent import FastAgentFramework __all__ = ["BaseFramework", "AgnoFramework", "FastAgentFramework"] diff --git a/src/agentman/frameworks/agno.py b/src/agentman/frameworks/agno.py index 5cad9cb..86e85e2 100644 --- a/src/agentman/frameworks/agno.py +++ b/src/agentman/frameworks/agno.py @@ -56,16 +56,18 @@ def build_agent_content(self) -> str: if not any("anthropic" in imp or "openai" in imp for imp in imports): # Default to both if model is not specified or unclear - imports.extend([ - "from agno.models.openai import OpenAILike", - "from agno.models.anthropic import Claude", - ]) + imports.extend( + [ + "from agno.models.openai import OpenAILike", + "from agno.models.anthropic import Claude", + ] + ) # Tool imports based on servers tool_imports = [] if has_servers: # Map server types to appropriate tools - for server_name, server in self.config.servers.items(): + for server_name, _ in self.config.servers.items(): if server_name in ["web_search", "search", "browser"]: tool_imports.append("from agno.tools.duckduckgo import DuckDuckGoTools") elif server_name in ["finance", "yfinance", "stock"]: @@ -86,15 +88,17 @@ def build_agent_content(self) -> str: imports.append("from agno.team.team import Team") # Advanced feature imports (always include for better examples) - imports.extend([ - "from agno.tools.reasoning import ReasoningTools", - "# Optional: Uncomment for advanced features", - "# from agno.storage.sqlite import SqliteStorage", - "# from agno.memory.v2.db.sqlite import SqliteMemoryDb", - "# from agno.memory.v2.memory import Memory", - "# from agno.knowledge.url import UrlKnowledge", - "# from agno.vectordb.lancedb import LanceDb", - ]) + imports.extend( + [ + "from agno.tools.reasoning import ReasoningTools", + "# Optional: Uncomment for advanced features", + "# from agno.storage.sqlite import SqliteStorage", + "# from agno.memory.v2.db.sqlite import SqliteMemoryDb", + "# from agno.memory.v2.memory import Memory", + "# from agno.knowledge.url import UrlKnowledge", + "# from agno.vectordb.lancedb import LanceDb", + ] + ) lines.extend(imports + [""]) @@ -104,12 +108,14 @@ def build_agent_content(self) -> str: agent_var = f"{agent.name.lower().replace('-', '_')}_agent" agent_vars.append((agent_var, agent)) - lines.extend([ - f"# Agent: {agent.name}", - f"{agent_var} = Agent(", - f' name="{agent.name}",', - f' instructions="""{agent.instruction}""",', - ]) + lines.extend( + [ + f"# Agent: {agent.name}", + f"{agent_var} = Agent(", + f' name="{agent.name}",', + f' instructions="""{agent.instruction}""",', + ] + ) # Add role if we have multiple agents if has_multiple_agents: @@ -154,26 +160,30 @@ def build_agent_content(self) -> str: lines.append(" human_input=True,") # Enhanced agent properties - lines.extend([ - " markdown=True,", - " add_datetime_to_instructions=True,", - " # Optional: Enable advanced features", - " # storage=SqliteStorage(table_name='agent_sessions', db_file='tmp/agent.db'),", - " # memory=Memory(model=Claude(id='claude-sonnet-4-20250514'), db=SqliteMemoryDb()),", - " # enable_agentic_memory=True,", - ")", - "" - ]) + lines.extend( + [ + " markdown=True,", + " add_datetime_to_instructions=True,", + " # Optional: Enable advanced features", + " # storage=SqliteStorage(table_name='agent_sessions', db_file='tmp/agent.db'),", + " # memory=Memory(model=Claude(id='claude-sonnet-4-20250514'), db=SqliteMemoryDb()),", + " # enable_agentic_memory=True,", + ")", + "", + ] + ) # Team creation for multi-agent scenarios if has_multiple_agents: team_name = "AgentTeam" - lines.extend([ - "# Multi-Agent Team", - f"{team_name.lower()} = Team(", - f' name="{team_name}",', - " mode='coordinate', # or 'sequential' for ordered execution", - ]) + lines.extend( + [ + "# Multi-Agent Team", + f"{team_name.lower()} = Team(", + f' name="{team_name}",', + " mode='coordinate', # or 'sequential' for ordered execution", + ] + ) # Use the first agent's model for team coordination if agent_vars: @@ -186,31 +196,35 @@ def build_agent_content(self) -> str: members_str = ", ".join(member_vars) lines.append(f' members=[{members_str}],') - lines.extend([ - " tools=[ReasoningTools(add_instructions=True)],", - " instructions=[", - " 'Collaborate to provide comprehensive responses',", - " 'Consider multiple perspectives and expertise areas',", - " 'Present findings in a structured, easy-to-follow format',", - " 'Only output the final consolidated response',", - " ],", - " markdown=True,", - " show_members_responses=True,", - " enable_agentic_context=True,", - " add_datetime_to_instructions=True,", - " success_criteria='The team has provided a complete and accurate response.',", - ")", - "" - ]) + lines.extend( + [ + " tools=[ReasoningTools(add_instructions=True)],", + " instructions=[", + " 'Collaborate to provide comprehensive responses',", + " 'Consider multiple perspectives and expertise areas',", + " 'Present findings in a structured, easy-to-follow format',", + " 'Only output the final consolidated response',", + " ],", + " markdown=True,", + " show_members_responses=True,", + " enable_agentic_context=True,", + " add_datetime_to_instructions=True,", + " success_criteria='The team has provided a complete and accurate response.',", + ")", + "", + ] + ) # Main function and execution logic lines.extend(self._generate_main_function(has_multiple_agents, agent_vars)) - lines.extend([ - "", - 'if __name__ == "__main__":', - " main()", - ]) + lines.extend( + [ + "", + 'if __name__ == "__main__":', + " main()", + ] + ) return "\n".join(lines) @@ -226,7 +240,7 @@ def _generate_model_code(self, model: str) -> str: return f'model=Claude(id="{model}"),' # OpenAI models - elif "openai" in model_lower or "gpt" in model_lower: + if "openai" in model_lower or "gpt" in model_lower: model_code = 'model=OpenAILike(\n' model_code += f' id="{model}",\n' model_code += ' api_key=os.getenv("OPENAI_API_KEY"),\n' @@ -235,8 +249,8 @@ def _generate_model_code(self, model: str) -> str: return model_code # Custom OpenAI-like models (with provider prefix) - elif "/" in model: - provider, model_name = model.split("/", 1) + if "/" in model: + provider, _ = model.split("/", 1) provider_upper = provider.upper() # Generate OpenAILike model with custom configuration @@ -248,24 +262,23 @@ def _generate_model_code(self, model: str) -> str: return model_code # Default to OpenAILike for unrecognized patterns - else: - # Check if we have OpenAI-like environment variables configured - has_openai_config = any( - (isinstance(secret, str) and secret in ["OPENAI_API_KEY", "OPENAI_BASE_URL"]) - or (hasattr(secret, 'name') and secret.name in ["OPENAI_API_KEY", "OPENAI_BASE_URL"]) - for secret in self.config.secrets - ) + # Check if we have OpenAI-like environment variables configured + has_openai_config = any( + (isinstance(secret, str) and secret in ["OPENAI_API_KEY", "OPENAI_BASE_URL"]) + or (hasattr(secret, 'name') and secret.name in ["OPENAI_API_KEY", "OPENAI_BASE_URL"]) + for secret in self.config.secrets + ) + + if has_openai_config: + # Use OpenAI environment variables for custom models + model_code = 'model=OpenAILike(\n' + model_code += f' id="{model}",\n' + model_code += ' api_key=os.getenv("OPENAI_API_KEY"),\n' + model_code += ' base_url=os.getenv("OPENAI_BASE_URL"),\n' + model_code += ' ),' + return model_code - if has_openai_config: - # Use OpenAI environment variables for custom models - model_code = 'model=OpenAILike(\n' - model_code += f' id="{model}",\n' - model_code += ' api_key=os.getenv("OPENAI_API_KEY"),\n' - model_code += ' base_url=os.getenv("OPENAI_BASE_URL"),\n' - model_code += ' ),' - return model_code - else: - return f'model=OpenAILike(id="{model}"),' + return f'model=OpenAILike(id="{model}"),' def _generate_main_function(self, has_multiple_agents: bool, agent_vars: list) -> List[str]: """Generate the main function and execution logic.""" @@ -273,93 +286,105 @@ def _generate_main_function(self, has_multiple_agents: bool, agent_vars: list) - # Handle prompt file loading if self.has_prompt_file: - lines.extend([ - " # Check if prompt.txt exists and load its content", - " import os", - " prompt_file = 'prompt.txt'", - " if os.path.exists(prompt_file):", - " with open(prompt_file, 'r', encoding='utf-8') as f:", - " prompt_content = f.read().strip()", - ]) + lines.extend( + [ + " # Check if prompt.txt exists and load its content", + " import os", + " prompt_file = 'prompt.txt'", + " if os.path.exists(prompt_file):", + " with open(prompt_file, 'r', encoding='utf-8') as f:", + " prompt_content = f.read().strip()", + ] + ) # Enhanced execution logic if has_multiple_agents: # Use team for multi-agent scenarios team_name = "AgentTeam" if self.has_prompt_file: - lines.extend([ - " if prompt_content:", - f" {team_name.lower()}.print_response(", - " prompt_content,", - " stream=True,", - " show_full_reasoning=True,", - " stream_intermediate_steps=True,", - " )", - " else:", - f" {team_name.lower()}.print_response(", - " 'Hello! How can our team help you today?',", - " stream=True,", - " show_full_reasoning=True,", - " stream_intermediate_steps=True,", - " )", - " else:", - f" {team_name.lower()}.print_response(", - " 'Hello! How can our team help you today?',", - " stream=True,", - " show_full_reasoning=True,", - " stream_intermediate_steps=True,", - " )", - ]) + lines.extend( + [ + " if prompt_content:", + f" {team_name.lower()}.print_response(", + " prompt_content,", + " stream=True,", + " show_full_reasoning=True,", + " stream_intermediate_steps=True,", + " )", + " else:", + f" {team_name.lower()}.print_response(", + " 'Hello! How can our team help you today?',", + " stream=True,", + " show_full_reasoning=True,", + " stream_intermediate_steps=True,", + " )", + " else:", + f" {team_name.lower()}.print_response(", + " 'Hello! How can our team help you today?',", + " stream=True,", + " show_full_reasoning=True,", + " stream_intermediate_steps=True,", + " )", + ] + ) else: - lines.extend([ - f" {team_name.lower()}.print_response(", - " 'Hello! How can our team help you today?',", - " stream=True,", - " show_full_reasoning=True,", - " stream_intermediate_steps=True,", - " )", - ]) + lines.extend( + [ + f" {team_name.lower()}.print_response(", + " 'Hello! How can our team help you today?',", + " stream=True,", + " show_full_reasoning=True,", + " stream_intermediate_steps=True,", + " )", + ] + ) elif agent_vars: # Single agent scenario with enhanced features - primary_agent_var, primary_agent = agent_vars[0] + primary_agent_var, _ = agent_vars[0] if self.has_prompt_file: - lines.extend([ - " if prompt_content:", - f" {primary_agent_var}.print_response(", - " prompt_content,", - " stream=True,", - " show_full_reasoning=True,", - " stream_intermediate_steps=True,", - " )", - " else:", - f" {primary_agent_var}.print_response(", - " 'Hello! How can I help you today?',", - " stream=True,", - " show_full_reasoning=True,", - " stream_intermediate_steps=True,", - " )", - " else:", - f" {primary_agent_var}.print_response(", - " 'Hello! How can I help you today?',", - " stream=True,", - " show_full_reasoning=True,", - " stream_intermediate_steps=True,", - " )", - ]) + lines.extend( + [ + " if prompt_content:", + f" {primary_agent_var}.print_response(", + " prompt_content,", + " stream=True,", + " show_full_reasoning=True,", + " stream_intermediate_steps=True,", + " )", + " else:", + f" {primary_agent_var}.print_response(", + " 'Hello! How can I help you today?',", + " stream=True,", + " show_full_reasoning=True,", + " stream_intermediate_steps=True,", + " )", + " else:", + f" {primary_agent_var}.print_response(", + " 'Hello! How can I help you today?',", + " stream=True,", + " show_full_reasoning=True,", + " stream_intermediate_steps=True,", + " )", + ] + ) else: - lines.extend([ - f" {primary_agent_var}.print_response(", - " 'Hello! How can I help you today?',", - " stream=True,", - " show_full_reasoning=True,", - " stream_intermediate_steps=True,", - " )", - ]) + lines.extend( + [ + f" {primary_agent_var}.print_response(", + " 'Hello! How can I help you today?',", + " stream=True,", + " show_full_reasoning=True,", + " stream_intermediate_steps=True,", + " )", + ] + ) else: - lines.extend([ - " print('No agents defined')", - ]) + lines.extend( + [ + " print('No agents defined')", + ] + ) return lines @@ -445,22 +470,26 @@ def get_requirements(self) -> List[str]: requirements.extend(server_reqs) # Always include core advanced features - requirements.extend([ - # Core MCP support - "mcp", - # Environment file support - "python-dotenv", - # Optional but commonly used packages - "sqlalchemy", # For storage and memory - "lancedb", # For knowledge and vector databases - "tantivy", # For hybrid search - ]) + requirements.extend( + [ + # Core MCP support + "mcp", + # Environment file support + "python-dotenv", + # Optional but commonly used packages + "sqlalchemy", # For storage and memory + "lancedb", # For knowledge and vector databases + "tantivy", # For hybrid search + ] + ) # Multi-agent scenarios get additional dependencies if len(self.config.agents) > 1: - requirements.extend([ - "asyncio", # Usually built-in but explicit for clarity - ]) + requirements.extend( + [ + "asyncio", # Usually built-in but explicit for clarity + ] + ) return requirements @@ -484,10 +513,12 @@ def _generate_env_file(self): env_lines.extend(["", "# Custom Model Provider Configuration"]) for provider in sorted(custom_providers): provider_upper = provider.upper() - env_lines.extend([ - f"# {provider_upper}_API_KEY=your-{provider}-api-key", - f"# {provider_upper}_BASE_URL=your-{provider}-base-url", - ]) + env_lines.extend( + [ + f"# {provider_upper}_API_KEY=your-{provider}-api-key", + f"# {provider_upper}_BASE_URL=your-{provider}-base-url", + ] + ) # Process secrets to generate environment variables for secret in self.config.secrets: diff --git a/src/agentman/frameworks/base.py b/src/agentman/frameworks/base.py index 3186674..2b6e160 100644 --- a/src/agentman/frameworks/base.py +++ b/src/agentman/frameworks/base.py @@ -1,8 +1,8 @@ """Base framework interface for AgentMan.""" from abc import ABC, abstractmethod -from typing import List from pathlib import Path +from typing import List from agentman.agentfile_parser import AgentfileConfig @@ -19,22 +19,18 @@ def __init__(self, config: AgentfileConfig, output_dir: Path, source_dir: Path): @abstractmethod def build_agent_content(self) -> str: """Build the main agent file content.""" - pass @abstractmethod def get_requirements(self) -> List[str]: """Get framework-specific requirements.""" - pass @abstractmethod def generate_config_files(self) -> None: """Generate framework-specific configuration files.""" - pass @abstractmethod def get_dockerfile_config_lines(self) -> List[str]: """Get framework-specific Dockerfile configuration lines.""" - pass def get_custom_model_providers(self) -> set: """Extract custom model providers from all models used.""" diff --git a/src/agentman/frameworks/fast_agent.py b/src/agentman/frameworks/fast_agent.py index 17661f2..0543378 100644 --- a/src/agentman/frameworks/fast_agent.py +++ b/src/agentman/frameworks/fast_agent.py @@ -1,6 +1,7 @@ """Fast-Agent framework implementation for AgentMan.""" from typing import List + import yaml from .base import BaseFramework @@ -14,18 +15,21 @@ def build_agent_content(self) -> str: lines = [] # Imports - lines.extend([ - "import asyncio", - "from mcp_agent.core.fastagent import FastAgent", - "", - "# Create the application", - 'fast = FastAgent("Generated by Agentman")', - "", - ]) + lines.extend( + [ + "import asyncio", + "from mcp_agent.core.fastagent import FastAgent", + "from mcp_agent.core.request_params import RequestParams", + "", + "# Create the application", + 'fast = FastAgent("Generated by Agentman")', + "", + ] + ) # Agent definitions for agent in self.config.agents.values(): - lines.append(agent.to_decorator_string(self.config.default_model)) + lines.append(agent.to_decorator_string(self.config.default_model, str(self.source_dir))) # Router definitions for router in self.config.routers.values(): @@ -40,36 +44,42 @@ def build_agent_content(self) -> str: lines.append(orchestrator.to_decorator_string(self.config.default_model)) # Main function - lines.extend([ - "async def main() -> None:", - " async with fast.run() as agent:", - ]) + lines.extend( + [ + "async def main() -> None:", + " async with fast.run() as agent:", + ] + ) # Check if prompt.txt exists and add prompt loading if self.has_prompt_file: - lines.extend([ - " # Check if prompt.txt exists and load its content", - " import os", - " prompt_file = 'prompt.txt'", - " if os.path.exists(prompt_file):", - " with open(prompt_file, 'r', encoding='utf-8') as f:", - " prompt_content = f.read().strip()", - " if prompt_content:", - " await agent(prompt_content)", - " else:", - " await agent()", - " else:", - " await agent()", - ]) + lines.extend( + [ + " # Check if prompt.txt exists and load its content", + " import os", + " prompt_file = 'prompt.txt'", + " if os.path.exists(prompt_file):", + " with open(prompt_file, 'r', encoding='utf-8') as f:", + " prompt_content = f.read().strip()", + " if prompt_content:", + " await agent(prompt_content)", + " else:", + " await agent()", + " else:", + " await agent()", + ] + ) else: lines.extend([" await agent()"]) - lines.extend([ - "", - "", - 'if __name__ == "__main__":', - " asyncio.run(main())", - ]) + lines.extend( + [ + "", + "", + 'if __name__ == "__main__":', + " asyncio.run(main())", + ] + ) return "\n".join(lines) diff --git a/src/agentman/yaml_parser.py b/src/agentman/yaml_parser.py new file mode 100644 index 0000000..32d7b45 --- /dev/null +++ b/src/agentman/yaml_parser.py @@ -0,0 +1,417 @@ +"""YAML parser module for parsing Agentfile configurations in YAML format.""" + +from pathlib import Path +from typing import Any, Dict, List, Union + +import yaml + +from agentman.agentfile_parser import ( + Agent, + AgentfileConfig, + AgentfileParser, + Chain, + DockerfileInstruction, + MCPServer, + Orchestrator, + OutputFormat, + Router, + SecretContext, + SecretValue, + expand_env_vars, +) + + +class AgentfileYamlParser: + """Parser for YAML format Agentfile configurations.""" + + def __init__(self): + self.config = AgentfileConfig() + + def parse_file(self, filepath: str) -> AgentfileConfig: + """Parse a YAML Agentfile and return the configuration.""" + with open(filepath, 'r', encoding='utf-8') as f: + content = f.read() + return self.parse_content(content) + + def parse_content(self, content: str) -> AgentfileConfig: + """Parse YAML Agentfile content and return the configuration.""" + try: + data = yaml.safe_load(content) + except yaml.YAMLError as e: + raise ValueError(f"Invalid YAML format: {e}") from e + + if not data: + return self.config + + # Validate API version and kind + api_version = data.get('apiVersion', 'v1') + kind = data.get('kind', 'Agent') + + if api_version != 'v1': + raise ValueError(f"Unsupported API version: {api_version}. Only 'v1' is supported.") + + if kind != 'Agent': + raise ValueError(f"Unsupported kind: {kind}. Only 'Agent' is supported.") + + # Parse base configuration + self._parse_base(data.get('base', {})) + + # Parse MCP servers + self._parse_mcp_servers(data.get('mcp_servers', [])) + + # Parse agents configuration - convert single agent to agents array + agents_to_parse = [] + + if 'agent' in data: + # Single agent configuration - treat as array with one agent + agents_to_parse.append(data['agent']) + + if 'agents' in data: + # Multiple agents configuration + agents_to_parse.extend(data['agents']) + + # Parse all agents + self._parse_agents(agents_to_parse) + + # Parse routers, chains, and orchestrators + self._parse_routers(data.get('routers', [])) + self._parse_chains(data.get('chains', [])) + self._parse_orchestrators(data.get('orchestrators', [])) + + # Parse command + self._parse_command(data.get('command', [])) + + # Parse secrets if they exist + self._parse_secrets(data.get('secrets', [])) + + # Parse expose ports if they exist + self._parse_expose_ports(data.get('expose', [])) + + # Parse additional dockerfile instructions if they exist + self._parse_dockerfile_instructions(data.get('dockerfile', [])) + + return self.config + + def _parse_base(self, base_config: Dict[str, Any]): + """Parse base configuration.""" + if 'image' in base_config: + self.config.base_image = base_config['image'] + + if 'model' in base_config: + self.config.default_model = base_config['model'] + + if 'framework' in base_config: + framework = base_config['framework'].lower() + if framework not in ['fast-agent', 'agno']: + raise ValueError(f"Unsupported framework: {framework}. Supported: fast-agent, agno") + self.config.framework = framework + + def _parse_mcp_servers(self, servers_config: List[Dict[str, Any]]): + """Parse MCP servers configuration.""" + for server_config in servers_config: + if 'name' not in server_config: + raise ValueError("MCP server must have a 'name' field") + + name = server_config['name'] + server = MCPServer(name=name) + + if 'command' in server_config: + server.command = server_config['command'] + + if 'args' in server_config: + args = server_config['args'] + if isinstance(args, list): + server.args = args + else: + raise ValueError("MCP server 'args' must be a list") + + if 'transport' in server_config: + transport = server_config['transport'] + if transport not in ['stdio', 'sse', 'http']: + raise ValueError(f"Invalid transport type: {transport}") + server.transport = transport + + if 'url' in server_config: + server.url = server_config['url'] + + if 'env' in server_config: + env = server_config['env'] + if isinstance(env, dict): + # Expand environment variables in values + expanded_env = {} + for key, value in env.items(): + expanded_env[key] = expand_env_vars(value) + server.env = expanded_env + else: + raise ValueError("MCP server 'env' must be a dictionary") + + self.config.servers[name] = server + + def _parse_agents(self, agents_config: List[Dict[str, Any]]): + """Parse agents configuration.""" + for agent_config in agents_config: + self._parse_agent(agent_config) + + def _parse_agent(self, agent_config: Dict[str, Any]): + """Parse agent configuration.""" + if not agent_config: + return + + if 'name' not in agent_config: + raise ValueError("Agent must have a 'name' field") + + name = agent_config['name'] + agent = Agent(name=name) + + if 'instruction' in agent_config: + agent.instruction = agent_config['instruction'] + + if 'servers' in agent_config: + servers = agent_config['servers'] + if isinstance(servers, list): + agent.servers = servers + else: + raise ValueError("Agent 'servers' must be a list") + + if 'model' in agent_config: + agent.model = agent_config['model'] + + if 'use_history' in agent_config: + agent.use_history = bool(agent_config['use_history']) + + if 'human_input' in agent_config: + agent.human_input = bool(agent_config['human_input']) + + if 'default' in agent_config: + agent.default = bool(agent_config['default']) + + if 'output_format' in agent_config: + output_format_config = agent_config['output_format'] + if not isinstance(output_format_config, dict): + raise ValueError("Agent 'output_format' must be an object") + + if 'type' not in output_format_config: + raise ValueError("Agent 'output_format' must have a 'type' field") + + format_type = output_format_config['type'] + + if format_type == 'json_schema': + if 'schema' not in output_format_config: + raise ValueError("Agent 'output_format' with type 'json_schema' must have a 'schema' field") + schema = output_format_config['schema'] + if not isinstance(schema, dict): + raise ValueError("Agent 'output_format' schema must be an object") + agent.output_format = OutputFormat(type='json_schema', schema=schema) + elif format_type == 'schema_file': + if 'file' not in output_format_config: + raise ValueError("Agent 'output_format' with type 'schema_file' must have a 'file' field") + file_path = output_format_config['file'] + if not isinstance(file_path, str): + raise ValueError("Agent 'output_format' file must be a string") + if not file_path.endswith(('.json', '.yaml', '.yml')): + raise ValueError("Agent 'output_format' file must reference a .json, .yaml, or .yml file") + agent.output_format = OutputFormat(type='schema_file', file=file_path) + else: + raise ValueError(f"Invalid output_format type: {format_type}. Supported: json_schema, schema_file") + + self.config.agents[name] = agent + + def _parse_routers(self, routers_config: List[Dict[str, Any]]): + """Parse routers configuration.""" + for router_config in routers_config: + if 'name' not in router_config: + raise ValueError("Router must have a 'name' field") + + name = router_config['name'] + router = Router(name=name) + + if 'agents' in router_config: + agents = router_config['agents'] + if isinstance(agents, list): + router.agents = agents + else: + raise ValueError("Router 'agents' must be a list") + + if 'model' in router_config: + router.model = router_config['model'] + + if 'instruction' in router_config: + router.instruction = router_config['instruction'] + + if 'default' in router_config: + router.default = bool(router_config['default']) + + self.config.routers[name] = router + + def _parse_chains(self, chains_config: List[Dict[str, Any]]): + """Parse chains configuration.""" + for chain_config in chains_config: + if 'name' not in chain_config: + raise ValueError("Chain must have a 'name' field") + + name = chain_config['name'] + chain = Chain(name=name) + + if 'sequence' in chain_config: + sequence = chain_config['sequence'] + if isinstance(sequence, list): + chain.sequence = sequence + else: + raise ValueError("Chain 'sequence' must be a list") + + if 'instruction' in chain_config: + chain.instruction = chain_config['instruction'] + + if 'cumulative' in chain_config: + chain.cumulative = bool(chain_config['cumulative']) + + if 'continue_with_final' in chain_config: + chain.continue_with_final = bool(chain_config['continue_with_final']) + + if 'default' in chain_config: + chain.default = bool(chain_config['default']) + + self.config.chains[name] = chain + + def _parse_orchestrators(self, orchestrators_config: List[Dict[str, Any]]): + """Parse orchestrators configuration.""" + for orchestrator_config in orchestrators_config: + if 'name' not in orchestrator_config: + raise ValueError("Orchestrator must have a 'name' field") + + name = orchestrator_config['name'] + orchestrator = Orchestrator(name=name) + + if 'agents' in orchestrator_config: + agents = orchestrator_config['agents'] + if isinstance(agents, list): + orchestrator.agents = agents + else: + raise ValueError("Orchestrator 'agents' must be a list") + + if 'model' in orchestrator_config: + orchestrator.model = orchestrator_config['model'] + + if 'instruction' in orchestrator_config: + orchestrator.instruction = orchestrator_config['instruction'] + + if 'plan_type' in orchestrator_config: + plan_type = orchestrator_config['plan_type'] + if plan_type not in ["full", "iterative"]: + raise ValueError(f"Invalid plan type: {plan_type}") + orchestrator.plan_type = plan_type + + if 'plan_iterations' in orchestrator_config: + orchestrator.plan_iterations = int(orchestrator_config['plan_iterations']) + + if 'human_input' in orchestrator_config: + orchestrator.human_input = bool(orchestrator_config['human_input']) + + if 'default' in orchestrator_config: + orchestrator.default = bool(orchestrator_config['default']) + + self.config.orchestrators[name] = orchestrator + + def _parse_command(self, command_config: List[str]): + """Parse command configuration.""" + if command_config: + if isinstance(command_config, list): + self.config.cmd = command_config + else: + raise ValueError("Command must be a list") + + def _parse_secrets(self, secrets_config: List[Union[str, Dict[str, Any]]]): + """Parse secrets configuration.""" + for secret_config in secrets_config: + if isinstance(secret_config, str): + # Simple secret reference + self.config.secrets.append(secret_config) + elif isinstance(secret_config, dict): + if 'name' not in secret_config: + raise ValueError("Secret must have a 'name' field") + + name = secret_config['name'] + + if 'value' in secret_config: + # Inline secret value + expanded_value = expand_env_vars(secret_config['value']) + secret = SecretValue(name=name, value=expanded_value) + self.config.secrets.append(secret) + elif 'values' in secret_config: + # Secret context with multiple values + values = secret_config['values'] + if isinstance(values, dict): + # Expand environment variables in values + expanded_values = {} + for key, value in values.items(): + expanded_values[key] = expand_env_vars(value) + secret = SecretContext(name=name, values=expanded_values) + self.config.secrets.append(secret) + else: + raise ValueError("Secret 'values' must be a dictionary") + else: + # Simple secret reference + self.config.secrets.append(name) + else: + raise ValueError("Secret must be a string or dictionary") + + def _parse_expose_ports(self, expose_config: List[int]): + """Parse expose ports configuration.""" + for port in expose_config: + if isinstance(port, int): + if port not in self.config.expose_ports: + self.config.expose_ports.append(port) + else: + raise ValueError("Expose port must be an integer") + + def _parse_dockerfile_instructions(self, dockerfile_config: List[Dict[str, Any]]): + """Parse additional dockerfile instructions.""" + for instruction_config in dockerfile_config: + if 'instruction' not in instruction_config or 'args' not in instruction_config: + raise ValueError("Dockerfile instruction must have 'instruction' and 'args' fields") + + instruction = instruction_config['instruction'].upper() + args = instruction_config['args'] + + if isinstance(args, list): + dockerfile_instruction = DockerfileInstruction(instruction=instruction, args=args) + self.config.dockerfile_instructions.append(dockerfile_instruction) + else: + raise ValueError("Dockerfile instruction 'args' must be a list") + + +def detect_yaml_format(filepath: str) -> bool: + """Detect if a file is in YAML format based on extension or content.""" + path = Path(filepath) + + # Check file extension + if path.suffix.lower() in ['.yml', '.yaml']: + return True + + # Check content for YAML structure + try: + with open(filepath, 'r', encoding='utf-8') as f: + content = f.read().strip() + if not content: + return False + + # Try to parse as YAML + data = yaml.safe_load(content) + + # Check if it has YAML Agentfile structure + if isinstance(data, dict) and 'apiVersion' in data and 'kind' in data: + return True + + return False + except (yaml.YAMLError, IOError, UnicodeDecodeError): + return False + + +def parse_agentfile(filepath: str) -> AgentfileConfig: + """Parse an Agentfile in either YAML or Dockerfile format.""" + if detect_yaml_format(filepath): + parser = AgentfileYamlParser() + return parser.parse_file(filepath) + + parser = AgentfileParser() + return parser.parse_file(filepath) diff --git a/tests/test_agent_builder.py b/tests/test_agent_builder.py index 76c455f..84f0d9f 100644 --- a/tests/test_agent_builder.py +++ b/tests/test_agent_builder.py @@ -10,23 +10,24 @@ - Integration with AgentfileConfig """ -import pytest -import tempfile import os -import yaml +import tempfile from pathlib import Path -from unittest.mock import patch, mock_open +from unittest.mock import mock_open, patch + +import pytest +import yaml from agentman.agent_builder import AgentBuilder, build_from_agentfile from agentman.agentfile_parser import ( - AgentfileConfig, - MCPServer, Agent, - Router, + AgentfileConfig, Chain, + MCPServer, Orchestrator, + Router, + SecretContext, SecretValue, - SecretContext ) @@ -369,27 +370,25 @@ def test_build_all(self): "fastagent.secrets.yaml", "Dockerfile", "requirements.txt", - ".dockerignore" + ".dockerignore", ] for filename in expected_files: file_path = Path(temp_dir) / filename assert file_path.exists(), f"File {filename} was not created" - @patch('agentman.agent_builder.AgentfileParser') - def test_build_from_agentfile(self, mock_parser_class): + @patch('agentman.agent_builder.parse_agentfile') + def test_build_from_agentfile(self, mock_parse_agentfile): """Test building from Agentfile function.""" - # Mock the parser and its behavior - mock_parser = mock_parser_class.return_value - mock_parser.parse_file.return_value = self.config + # Mock the parser function and its behavior + mock_parse_agentfile.return_value = self.config with tempfile.TemporaryDirectory() as temp_dir: # Call the function build_from_agentfile("test_agentfile", temp_dir) # Verify parser was called correctly - mock_parser_class.assert_called_once() - mock_parser.parse_file.assert_called_once_with("test_agentfile") + mock_parse_agentfile.assert_called_once_with("test_agentfile") # Check that files were created expected_files = [ @@ -398,7 +397,7 @@ def test_build_from_agentfile(self, mock_parser_class): "fastagent.secrets.yaml", "Dockerfile", "requirements.txt", - ".dockerignore" + ".dockerignore", ] for filename in expected_files: @@ -407,9 +406,8 @@ def test_build_from_agentfile(self, mock_parser_class): def test_build_from_agentfile_default_output(self): """Test building from Agentfile with default output directory.""" - with patch('agentman.agent_builder.AgentfileParser') as mock_parser_class: - mock_parser = mock_parser_class.return_value - mock_parser.parse_file.return_value = self.config + with patch('agentman.agent_builder.parse_agentfile') as mock_parse_agentfile: + mock_parse_agentfile.return_value = self.config # Mock the AgentBuilder.build_all method to avoid actual file creation with patch.object(AgentBuilder, 'build_all') as mock_build_all: @@ -488,7 +486,7 @@ def test_empty_config(self): "fastagent.secrets.yaml", "Dockerfile", "requirements.txt", - ".dockerignore" + ".dockerignore", ] for filename in expected_files: diff --git a/tests/test_agentfile_parser.py b/tests/test_agentfile_parser.py index cc7db7e..2c470d3 100644 --- a/tests/test_agentfile_parser.py +++ b/tests/test_agentfile_parser.py @@ -11,20 +11,21 @@ - Error handling and validation """ -import pytest -import tempfile import os +import tempfile + +import pytest from agentman.agentfile_parser import ( - AgentfileParser, - AgentfileConfig, - MCPServer, Agent, - Router, + AgentfileConfig, + AgentfileParser, Chain, + MCPServer, Orchestrator, + Router, + SecretContext, SecretValue, - SecretContext ) @@ -181,11 +182,7 @@ def test_parse_content_with_secret_context_arbitrary_name(self): def _find_instruction_by_type(self, instructions, instruction_type): """Helper function to find instruction by type.""" return next( - ( - instruction - for instruction in instructions - if instruction.instruction == instruction_type - ), + (instruction for instruction in instructions if instruction.instruction == instruction_type), None, ) @@ -293,7 +290,7 @@ def test_parse_multiple_dockerfile_instructions(self): ("COPY", [".", "."]), ("RUN", ["pip", "install", "-r", "requirements.txt"]), ("EXPOSE", ["8080"]), - ("CMD", ["python", "app.py"]) + ("CMD", ["python", "app.py"]), ] assert len(config.dockerfile_instructions) == len(expected_instructions) @@ -449,10 +446,12 @@ def test_multiline_instruction_syntax(self): assert agent.servers == ["fetch", "github-mcp-server"] # Check that the multiline instruction is properly combined - expected_instruction = ('Given a GitHub repository URL, find the latest **official release** of the repository. ' - 'An official release is one that is explicitly marked as **"Latest"** and **not** marked as a **"Pre-release"**. ' - 'If you encounter a release marked as **Pre-release**, do **not** stop or return it. ' - 'Instead, continue checking additional releases until you find the most recent release that meets the criteria.') + expected_instruction = ( + 'Given a GitHub repository URL, find the latest **official release** of the repository. ' + 'An official release is one that is explicitly marked as **"Latest"** and **not** marked as a **"Pre-release"**. ' + 'If you encounter a release marked as **Pre-release**, do **not** stop or return it. ' + 'Instead, continue checking additional releases until you find the most recent release that meets the criteria.' + ) assert agent.instruction == expected_instruction def test_multiline_instruction_complex_syntax(self): @@ -475,16 +474,20 @@ def test_multiline_instruction_complex_syntax(self): agent = config.agents["complex-agent"] # Check that all lines are properly combined with spaces - expected_instruction = ('This is a very long instruction that spans multiple lines ' - 'and contains detailed explanations about what the agent should do. ' - 'It includes specific requirements, formatting instructions, ' - 'and examples of the expected output format. ' - 'The agent should handle edge cases gracefully ' - 'and provide comprehensive responses.') + expected_instruction = ( + 'This is a very long instruction that spans multiple lines ' + 'and contains detailed explanations about what the agent should do. ' + 'It includes specific requirements, formatting instructions, ' + 'and examples of the expected output format. ' + 'The agent should handle edge cases gracefully ' + 'and provide comprehensive responses.' + ) assert agent.instruction == expected_instruction assert agent.servers == ["server1", "server2"] # ...existing code... + + class TestDataClasses: """Test suite for data classes used by AgentfileParser.""" @@ -496,7 +499,7 @@ def test_mcp_server_creation(self): args=["tool", "run"], transport="stdio", url="http://localhost", - env={"KEY": "value"} + env={"KEY": "value"}, ) assert server.name == "test" assert server.command == "uv" @@ -532,10 +535,7 @@ def test_secret_value_creation(self): def test_secret_context_creation(self): """Test SecretContext data class creation.""" - secret = SecretContext( - name="GENERIC", - values={"API_KEY": "value", "BASE_URL": "url"} - ) + secret = SecretContext(name="GENERIC", values={"API_KEY": "value", "BASE_URL": "url"}) assert secret.name == "GENERIC" assert secret.values == {"API_KEY": "value", "BASE_URL": "url"} @@ -545,7 +545,7 @@ def test_router_creation(self): name="multi_agent", agents=["agent1", "agent2"], model="anthropic/claude-3-sonnet-20241022", - instruction="Route requests" + instruction="Route requests", ) assert router.name == "multi_agent" assert router.agents == ["agent1", "agent2"] @@ -555,11 +555,7 @@ def test_router_creation(self): def test_chain_creation(self): """Test Chain data class creation.""" - chain = Chain( - name="sequential", - sequence=["agent1", "agent2"], - instruction="Process sequentially" - ) + chain = Chain(name="sequential", sequence=["agent1", "agent2"], instruction="Process sequentially") assert chain.name == "sequential" assert chain.sequence == ["agent1", "agent2"] assert chain.instruction == "Process sequentially" diff --git a/tests/test_dockerfile_generation.py b/tests/test_dockerfile_generation.py index bf9ff39..b71a5a1 100644 --- a/tests/test_dockerfile_generation.py +++ b/tests/test_dockerfile_generation.py @@ -1,12 +1,12 @@ #!/usr/bin/env python3 """Test script to verify EXPOSE and CMD instructions are properly handled in Dockerfile generation.""" -import tempfile import os +import tempfile from pathlib import Path -from agentman.agentfile_parser import AgentfileParser from agentman.agent_builder import AgentBuilder +from agentman.agentfile_parser import AgentfileParser def test_dockerfile_generation_with_expose_and_cmd(): diff --git a/tests/test_framework_support.py b/tests/test_framework_support.py index a13957b..479c72d 100644 --- a/tests/test_framework_support.py +++ b/tests/test_framework_support.py @@ -1,11 +1,13 @@ """Tests for framework support functionality.""" -import pytest -from src.agentman.agentfile_parser import AgentfileParser -from src.agentman.agent_builder import AgentBuilder import tempfile from pathlib import Path +import pytest + +from src.agentman.agent_builder import AgentBuilder +from src.agentman.agentfile_parser import AgentfileParser + class TestFrameworkSupport: """Test framework detection and code generation.""" diff --git a/tests/test_prompt_txt_support.py b/tests/test_prompt_txt_support.py index 3744e8c..ffc9b0f 100644 --- a/tests/test_prompt_txt_support.py +++ b/tests/test_prompt_txt_support.py @@ -1,12 +1,12 @@ #!/usr/bin/env python3 """Test script to verify prompt.txt support in AgentBuilder.""" -import tempfile import os +import tempfile from pathlib import Path -from agentman.agentfile_parser import AgentfileParser from agentman.agent_builder import AgentBuilder +from agentman.agentfile_parser import AgentfileParser def test_prompt_txt_support(): @@ -60,7 +60,9 @@ def test_prompt_txt_support(): agent_content = f.read() assert "prompt_file = 'prompt.txt'" in agent_content, "Agent should check for prompt.txt" - assert "with open(prompt_file, 'r', encoding='utf-8') as f:" in agent_content, "Agent should read prompt.txt" + assert ( + "with open(prompt_file, 'r', encoding='utf-8') as f:" in agent_content + ), "Agent should read prompt.txt" assert "await agent(prompt_content)" in agent_content, "Agent should use prompt content" # Verify Dockerfile contains COPY prompt.txt diff --git a/tests/test_yaml_parser.py b/tests/test_yaml_parser.py new file mode 100644 index 0000000..394a6a1 --- /dev/null +++ b/tests/test_yaml_parser.py @@ -0,0 +1,631 @@ +""" +Unit tests for yaml_parser module. + +Tests cover all aspects of the AgentfileYamlParser including: +- Basic YAML parsing functionality +- Schema validation +- Format detection +- Error handling +- All configuration sections (base, mcp_servers, agent, command, secrets, etc.) +""" + +import os +import tempfile + +import pytest + +from agentman.agentfile_parser import ( + AgentfileConfig, + SecretContext, + SecretValue, +) +from agentman.yaml_parser import ( + AgentfileYamlParser, + detect_yaml_format, + parse_agentfile, +) + + +class TestAgentfileYamlParser: + """Test suite for AgentfileYamlParser class.""" + + def setup_method(self): + """Set up test fixtures.""" + self.parser = AgentfileYamlParser() + + def test_init(self): + """Test parser initialization.""" + assert self.parser.config is not None + assert isinstance(self.parser.config, AgentfileConfig) + assert self.parser.config.base_image == "yeahdongcn/agentman-base:latest" + assert self.parser.config.secrets == [] + assert self.parser.config.servers == {} + assert self.parser.config.agents == {} + + def test_parse_content_basic(self): + """Test parsing basic YAML Agentfile content.""" + content = """ +apiVersion: v1 +kind: Agent + +base: + image: python:3.11-slim + model: gpt-4 + framework: fast-agent + +command: [python, agent.py] + +expose: + - 8080 +""" + config = self.parser.parse_content(content) + + assert config.base_image == "python:3.11-slim" + assert config.default_model == "gpt-4" + assert config.framework == "fast-agent" + assert config.cmd == ["python", "agent.py"] + assert config.expose_ports == [8080] + + def test_parse_content_with_mcp_servers(self): + """Test parsing YAML with MCP servers.""" + content = """ +apiVersion: v1 +kind: Agent + +mcp_servers: + - name: filesystem + command: uv + args: [tool, run, mcp-server-filesystem, /tmp] + transport: stdio + env: + PATH: /usr/local/bin + DEBUG: "true" + - name: web_search + command: uvx + args: [mcp-server-fetch] + transport: stdio + +agent: + name: assistant + servers: [filesystem, web_search] +""" + config = self.parser.parse_content(content) + + assert len(config.servers) == 2 + assert "filesystem" in config.servers + assert "web_search" in config.servers + + fs_server = config.servers["filesystem"] + assert fs_server.name == "filesystem" + assert fs_server.command == "uv" + assert fs_server.args == ["tool", "run", "mcp-server-filesystem", "/tmp"] + assert fs_server.transport == "stdio" + assert fs_server.env == {"PATH": "/usr/local/bin", "DEBUG": "true"} + + web_server = config.servers["web_search"] + assert web_server.name == "web_search" + assert web_server.command == "uvx" + assert web_server.args == ["mcp-server-fetch"] + assert web_server.transport == "stdio" + + def test_parse_content_with_agent(self): + """Test parsing YAML with agent configuration.""" + content = """ +apiVersion: v1 +kind: Agent + +agent: + name: gmail_assistant + instruction: | + You are a helpful assistant that can manage Gmail. + Use the Gmail API to read, send, and organize emails. + servers: [gmail, fetch] + model: gpt-4 + use_history: true + human_input: false + default: true +""" + config = self.parser.parse_content(content) + + assert len(config.agents) == 1 + assert "gmail_assistant" in config.agents + + agent = config.agents["gmail_assistant"] + assert agent.name == "gmail_assistant" + assert "You are a helpful assistant that can manage Gmail." in agent.instruction + assert agent.servers == ["gmail", "fetch"] + assert agent.model == "gpt-4" + assert agent.use_history is True + assert agent.human_input is False + assert agent.default is True + + def test_parse_content_with_secrets(self): + """Test parsing YAML with various secret formats.""" + content = """ +apiVersion: v1 +kind: Agent + +secrets: + - SIMPLE_SECRET + - name: INLINE_SECRET + value: secret-value-123 + - name: OPENAI_CONFIG + values: + API_KEY: sk-test123 + BASE_URL: https://api.openai.com/v1 +""" + config = self.parser.parse_content(content) + + assert len(config.secrets) == 3 + + # Simple secret reference + assert config.secrets[0] == "SIMPLE_SECRET" + + # Inline secret value + inline_secret = config.secrets[1] + assert isinstance(inline_secret, SecretValue) + assert inline_secret.name == "INLINE_SECRET" + assert inline_secret.value == "secret-value-123" + + # Secret context + context_secret = config.secrets[2] + assert isinstance(context_secret, SecretContext) + assert context_secret.name == "OPENAI_CONFIG" + assert context_secret.values == {"API_KEY": "sk-test123", "BASE_URL": "https://api.openai.com/v1"} + + def test_parse_content_with_dockerfile_instructions(self): + """Test parsing YAML with additional dockerfile instructions.""" + content = """ +apiVersion: v1 +kind: Agent + +dockerfile: + - instruction: RUN + args: [apt-get, update] + - instruction: ENV + args: [PYTHONPATH=/app] + - instruction: COPY + args: [., /app] +""" + config = self.parser.parse_content(content) + + assert len(config.dockerfile_instructions) == 3 + + run_instruction = config.dockerfile_instructions[0] + assert run_instruction.instruction == "RUN" + assert run_instruction.args == ["apt-get", "update"] + + env_instruction = config.dockerfile_instructions[1] + assert env_instruction.instruction == "ENV" + assert env_instruction.args == ["PYTHONPATH=/app"] + + copy_instruction = config.dockerfile_instructions[2] + assert copy_instruction.instruction == "COPY" + assert copy_instruction.args == [".", "/app"] + + def test_parse_file(self): + """Test parsing YAML Agentfile from file.""" + content = """ +apiVersion: v1 +kind: Agent + +base: + image: python:3.11-slim + model: gpt-4 + +agent: + name: test_agent +""" + + with tempfile.NamedTemporaryFile(mode='w', suffix='.yml', delete=False) as f: + f.write(content) + f.flush() + + try: + config = self.parser.parse_file(f.name) + assert config.base_image == "python:3.11-slim" + assert config.default_model == "gpt-4" + assert "test_agent" in config.agents + finally: + os.unlink(f.name) + + def test_parse_file_not_exists(self): + """Test parsing from non-existent file raises error.""" + with pytest.raises(FileNotFoundError): + self.parser.parse_file("/non/existent/file.yml") + + def test_parse_invalid_yaml(self): + """Test parsing invalid YAML raises error.""" + content = """ +apiVersion: v1 +kind: Agent +invalid_yaml: [unclosed list +""" + with pytest.raises(ValueError, match="Invalid YAML format"): + self.parser.parse_content(content) + + def test_parse_invalid_api_version(self): + """Test parsing with invalid API version raises error.""" + content = """ +apiVersion: v2 +kind: Agent +""" + with pytest.raises(ValueError, match="Unsupported API version"): + self.parser.parse_content(content) + + def test_parse_invalid_kind(self): + """Test parsing with invalid kind raises error.""" + content = """ +apiVersion: v1 +kind: InvalidKind +""" + with pytest.raises(ValueError, match="Unsupported kind"): + self.parser.parse_content(content) + + def test_parse_invalid_framework(self): + """Test parsing with invalid framework raises error.""" + content = """ +apiVersion: v1 +kind: Agent + +base: + framework: invalid-framework +""" + with pytest.raises(ValueError, match="Unsupported framework"): + self.parser.parse_content(content) + + def test_parse_missing_agent_name(self): + """Test parsing with missing agent name raises error.""" + content = """ +apiVersion: v1 +kind: Agent + +agent: + instruction: Test instruction +""" + with pytest.raises(ValueError, match="Agent must have a 'name' field"): + self.parser.parse_content(content) + + def test_parse_missing_server_name(self): + """Test parsing with missing server name raises error.""" + content = """ +apiVersion: v1 +kind: Agent + +mcp_servers: + - command: test +""" + with pytest.raises(ValueError, match="MCP server must have a 'name' field"): + self.parser.parse_content(content) + + def test_empty_yaml_file(self): + """Test parsing empty YAML file.""" + config = self.parser.parse_content("") + assert config.base_image == "yeahdongcn/agentman-base:latest" + assert len(config.secrets) == 0 + assert len(config.servers) == 0 + assert len(config.agents) == 0 + + def test_parse_complete_example(self): + """Test parsing a complete YAML Agentfile example.""" + content = """ +apiVersion: v1 +kind: Agent + +base: + image: ghcr.io/o3-cloud/pai/base:latest + model: gpt-4.1 + framework: fast-agent + +mcp_servers: + - name: gmail + command: npx + args: [-y, "@gongrzhe/server-gmail-autoauth-mcp"] + transport: stdio + + - name: fetch + command: uvx + args: [mcp-server-fetch] + transport: stdio + +agent: + name: gmail_actions + instruction: | + You are a productivity assistant with access to my Gmail inbox. + Using my personal context, perform the following tasks: + 1. Only analyze and classify all emails currently in my inbox. + 2. Assign appropriate labels to each email based on inferred categories. + 3. Archive each email to keep my inbox clean. + servers: [gmail, fetch] + use_history: true + human_input: false + default: true + +command: [python, agent.py, -p, prompt.txt, --agent, gmail_actions] + +secrets: + - GMAIL_API_KEY + - name: OPENAI_CONFIG + values: + API_KEY: your-openai-api-key + BASE_URL: https://api.openai.com/v1 + +expose: + - 8080 + +dockerfile: + - instruction: RUN + args: [apt-get, update, "&&", apt-get, install, -y, curl] + - instruction: ENV + args: [PYTHONPATH=/app] +""" + config = self.parser.parse_content(content) + + # Verify base configuration + assert config.base_image == "ghcr.io/o3-cloud/pai/base:latest" + assert config.default_model == "gpt-4.1" + assert config.framework == "fast-agent" + + # Verify MCP servers + assert len(config.servers) == 2 + assert "gmail" in config.servers + assert "fetch" in config.servers + + # Verify agent + assert len(config.agents) == 1 + assert "gmail_actions" in config.agents + agent = config.agents["gmail_actions"] + assert agent.default is True + assert agent.servers == ["gmail", "fetch"] + + # Verify command + assert config.cmd == ["python", "agent.py", "-p", "prompt.txt", "--agent", "gmail_actions"] + + # Verify secrets + assert len(config.secrets) == 2 + assert config.secrets[0] == "GMAIL_API_KEY" + + # Verify expose + assert config.expose_ports == [8080] + + # Verify dockerfile instructions + assert len(config.dockerfile_instructions) == 2 + + +class TestFormatDetection: + """Test suite for format detection functionality.""" + + def setup_method(self): + """Set up test fixtures.""" + self.parser = AgentfileYamlParser() + + def test_detect_yaml_format_by_extension(self): + """Test detecting YAML format by file extension.""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.yml', delete=False) as f: + f.write("apiVersion: v1\nkind: Agent\n") + f.flush() + + try: + assert detect_yaml_format(f.name) is True + finally: + os.unlink(f.name) + + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + f.write("apiVersion: v1\nkind: Agent\n") + f.flush() + + try: + assert detect_yaml_format(f.name) is True + finally: + os.unlink(f.name) + + def test_detect_yaml_format_by_content(self): + """Test detecting YAML format by content structure.""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f: + f.write("apiVersion: v1\nkind: Agent\n") + f.flush() + + try: + assert detect_yaml_format(f.name) is True + finally: + os.unlink(f.name) + + def test_detect_dockerfile_format(self): + """Test detecting Dockerfile format.""" + with tempfile.NamedTemporaryFile(mode='w', delete=False) as f: + f.write("FROM python:3.11-slim\nMODEL gpt-4\n") + f.flush() + + try: + assert detect_yaml_format(f.name) is False + finally: + os.unlink(f.name) + + def test_detect_empty_file(self): + """Test detecting format for empty file.""" + with tempfile.NamedTemporaryFile(mode='w', delete=False) as f: + f.write("") + f.flush() + + try: + assert detect_yaml_format(f.name) is False + finally: + os.unlink(f.name) + + def test_parse_agentfile_auto_detect(self): + """Test parse_agentfile with auto-detection.""" + # Test YAML format + yaml_content = """ +apiVersion: v1 +kind: Agent + +base: + image: python:3.11-slim + model: gpt-4 + +agent: + name: test_agent +""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.yml', delete=False) as f: + f.write(yaml_content) + f.flush() + + try: + config = parse_agentfile(f.name) + assert config.base_image == "python:3.11-slim" + assert config.default_model == "gpt-4" + assert "test_agent" in config.agents + finally: + os.unlink(f.name) + + # Test Dockerfile format + dockerfile_content = """ +FROM python:3.11-slim +MODEL gpt-4 + +AGENT test_agent +""" + with tempfile.NamedTemporaryFile(mode='w', delete=False) as f: + f.write(dockerfile_content) + f.flush() + + try: + config = parse_agentfile(f.name) + assert config.base_image == "python:3.11-slim" + assert config.default_model == "gpt-4" + assert "test_agent" in config.agents + finally: + os.unlink(f.name) + + def test_parse_multiple_agents_yaml(self): + """Test parsing YAML with multiple agents.""" + yaml_content = """ +apiVersion: v1 +kind: Agent +base: + model: deepseek/deepseek-chat + framework: agno +mcp_servers: +- name: web_search + command: uvx + args: + - mcp-server-duckduckgo +- name: finance + command: uvx + args: + - mcp-server-yfinance +agents: +- name: research_coordinator + instruction: You are a research coordinator who plans and manages research projects. + servers: + - web_search + model: deepseek/deepseek-chat +- name: data_analyst + instruction: You are a financial data analyst specialized in stock analysis. + servers: + - finance + model: openai/gpt-4o +- name: content_creator + instruction: You are a content creator who synthesizes research findings. + servers: [] + model: deepseek/deepseek-chat +""" + config = self.parser.parse_content(yaml_content) + + # Verify all agents are parsed + assert len(config.agents) == 3 + assert "research_coordinator" in config.agents + assert "data_analyst" in config.agents + assert "content_creator" in config.agents + + # Verify agent properties + coordinator = config.agents["research_coordinator"] + assert coordinator.name == "research_coordinator" + assert "research coordinator" in coordinator.instruction + assert coordinator.servers == ["web_search"] + assert coordinator.model == "deepseek/deepseek-chat" + + analyst = config.agents["data_analyst"] + assert analyst.name == "data_analyst" + assert "financial data analyst" in analyst.instruction + assert analyst.servers == ["finance"] + assert analyst.model == "openai/gpt-4o" + + creator = config.agents["content_creator"] + assert creator.name == "content_creator" + assert "content creator" in creator.instruction + assert creator.servers == [] + assert creator.model == "deepseek/deepseek-chat" + + def test_convert_multiple_agents_to_yaml(self): + """Test converting multiple agents from Dockerfile to YAML format.""" + # Import converter function + from agentman.agentfile_parser import AgentfileParser + from agentman.converter import config_to_yaml_dict + + # Parse a Dockerfile format with multiple agents + dockerfile_content = """ +FROM yeahdongcn/agentman-base:latest +FRAMEWORK agno +MODEL deepseek/deepseek-chat + +SECRET DEEPSEEK_API_KEY +SECRET OPENAI_API_KEY + +MCP_SERVER web_search +COMMAND uvx +ARGS mcp-server-duckduckgo + +MCP_SERVER finance +COMMAND uvx +ARGS mcp-server-yfinance + +AGENT research_coordinator +INSTRUCTION You are a research coordinator who plans and manages research projects. +SERVERS web_search +MODEL deepseek/deepseek-chat + +AGENT data_analyst +INSTRUCTION You are a financial data analyst specialized in stock analysis. +SERVERS finance +MODEL openai/gpt-4o + +AGENT content_creator +INSTRUCTION You are a content creator who synthesizes research findings. +MODEL deepseek/deepseek-chat +""" + + parser = AgentfileParser() + config = parser.parse_content(dockerfile_content) + + # Convert to YAML + yaml_dict = config_to_yaml_dict(config) + + # Verify the YAML structure has agents (plural) + assert "agents" in yaml_dict + assert len(yaml_dict["agents"]) == 3 + + # Verify agent names + agent_names = [agent["name"] for agent in yaml_dict["agents"]] + assert "research_coordinator" in agent_names + assert "data_analyst" in agent_names + assert "content_creator" in agent_names + + # Verify agent details + coordinator = next(a for a in yaml_dict["agents"] if a["name"] == "research_coordinator") + assert "research coordinator" in coordinator["instruction"] + assert coordinator["servers"] == ["web_search"] + assert coordinator["model"] == "deepseek/deepseek-chat" + + analyst = next(a for a in yaml_dict["agents"] if a["name"] == "data_analyst") + assert "financial data analyst" in analyst["instruction"] + assert analyst["servers"] == ["finance"] + assert analyst["model"] == "openai/gpt-4o" + + creator = next(a for a in yaml_dict["agents"] if a["name"] == "content_creator") + assert "content creator" in creator["instruction"] + assert creator["model"] == "deepseek/deepseek-chat" + + +if __name__ == "__main__": + pytest.main([__file__])