diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..79b50f3 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,4 @@ +{ + "includeCoAuthoredBy": false, + "gitAttribution": false +} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..f299ad8 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,112 @@ +name: CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +permissions: + contents: read + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + + - name: Verify dependencies + run: | + cd forge-core && go mod verify + cd ../forge-cli && go mod verify + cd ../forge-plugins && go mod verify + + - name: Vet + run: go vet ./forge-core/... ./forge-cli/... ./forge-plugins/... + + - name: Check formatting + run: test -z "$(gofmt -l forge-core forge-cli forge-plugins)" + + - name: Test + run: go test -race -coverprofile=coverage.out ./forge-core/... ./forge-cli/... ./forge-plugins/... + + - name: Check coverage threshold + run: | + COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | tr -d '%') + echo "Total coverage: ${COVERAGE}%" + if (( $(echo "$COVERAGE < 70" | bc -l) )); then + echo "::warning::Coverage ${COVERAGE}% is below 70% threshold" + fi + + - name: Upload coverage + uses: actions/upload-artifact@v4 + with: + name: coverage + path: coverage.out + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + + - uses: golangci/golangci-lint-action@v7 + with: + version: v2.10.1 + args: ./forge-core/... ./forge-cli/... ./forge-plugins/... + + integration: + name: Integration Tests + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + + - name: Integration tests + run: go test -race -tags=integration ./forge-core/... ./forge-cli/... ./forge-plugins/... + + build: + name: Build (${{ matrix.goos }}/${{ matrix.goarch }}) + runs-on: ubuntu-latest + needs: [test, lint] + strategy: + matrix: + include: + - goos: linux + goarch: amd64 + - goos: linux + goarch: arm64 + - goos: darwin + goarch: amd64 + - goos: darwin + goarch: arm64 + - goos: windows + goarch: amd64 + - goos: windows + goarch: arm64 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + + - name: Build + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: "0" + run: cd forge-cli && go build -ldflags "-s -w" -o ../forge-${{ matrix.goos }}-${{ matrix.goarch }} ./cmd/forge diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..73c77b5 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,29 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + release: + name: Release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + + - uses: goreleaser/goreleaser-action@v6 + with: + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..113b199 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Code coverage profiles and other test artifacts +*.out +coverage.* +*.coverprofile +profile.cov + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +# go.work +# go.work.sum + +# env file +.env + +# Editor/IDE +# .idea/ +# .vscode/ + + +# Binary +/forge + +# Build output +.forge-output/ + +# OS files +.DS_Store diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..0f8edb7 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,44 @@ +version: 2 + +builds: + - dir: forge-cli + main: ./cmd/forge + binary: forge + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + ldflags: + - -s -w -X main.version={{.Version}} + +archives: + - formats: + - tar.gz + format_overrides: + - goos: windows + formats: + - zip + name_template: "forge-{{ .Os }}-{{ .Arch }}" + replacements: + linux: Linux + darwin: Darwin + windows: Windows + amd64: x86_64 # matches uname -m + arm64: arm64 # matches uname -m + +checksum: + name_template: "checksums.txt" + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^ci:" + - "^chore:" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a72ddaa --- /dev/null +++ b/Makefile @@ -0,0 +1,59 @@ +BINARY := forge +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none) +LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) +COVERFILE := coverage.out +MODULES := forge-core forge-cli forge-plugins + +.PHONY: build test test-integration vet fmt lint cover cover-html install clean release help + +## build: Compile the forge binary +build: + cd forge-cli && go build -ldflags "$(LDFLAGS)" -o ../$(BINARY) ./cmd/forge + +## test: Run all unit tests with race detection across all modules +test: + @for mod in $(MODULES); do echo "==> Testing $$mod"; (cd $$mod && go test -race ./...); done + +## test-integration: Run integration tests (requires build tag) +test-integration: + @for mod in $(MODULES); do echo "==> Integration testing $$mod"; (cd $$mod && go test -race -tags=integration ./...); done + +## vet: Run go vet on all modules +vet: + @for mod in $(MODULES); do echo "==> Vetting $$mod"; (cd $$mod && go vet ./...); done + +## fmt: Check that all Go files are gofmt-compliant +fmt: + @test -z "$$(gofmt -l .)" || (echo "Files not formatted:"; gofmt -l .; exit 1) + +## lint: Run golangci-lint on all modules (must be installed separately) +lint: + @for mod in $(MODULES); do echo "==> Linting $$mod"; (cd $$mod && golangci-lint run ./...); done + +## cover: Generate test coverage report for all modules +cover: + @for mod in $(MODULES); do echo "==> Coverage $$mod"; (cd $$mod && go test -race -coverprofile=$(COVERFILE) ./... && go tool cover -func=$(COVERFILE)); done + +## cover-html: Open coverage report in browser (forge-cli) +cover-html: + cd forge-cli && go test -race -coverprofile=$(COVERFILE) ./... && go tool cover -html=$(COVERFILE) + +## install: Install forge to GOPATH/bin +install: + cd forge-cli && go install -ldflags "$(LDFLAGS)" ./cmd/forge + +## clean: Remove build artifacts and coverage files +clean: + rm -f $(BINARY) + @for mod in $(MODULES); do rm -f $$mod/$(COVERFILE); done + +## release: Build a snapshot release using goreleaser +release: + goreleaser release --snapshot --clean + +## help: Show this help message +help: + @echo "Usage: make [target]" + @echo "" + @sed -n 's/^## //p' $(MAKEFILE_LIST) | column -t -s ':' diff --git a/README.md b/README.md index ef02b01..f22def3 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,404 @@ -# forge -Converts skills.md / agent definitions into portable, deployable A2A agent artifacts Generates: AgentSpec, tool schema, policy scaffolding, container build, k8s manifests Includes local runner + SDKs for dev-mode +# ✨ Forge + +Turn a `SKILL.md` into a portable, secure, runnable AI agent. + +Forge is a portable runtime for building and running secure AI agents from simple skill definitions. It take Agent Skills and makes it: + +* A runnable AI agent +* A portable bundle +* A local HTTP / A2A service +* A Slack or Telegram bot +* A secure, restricted execution environment + +No Docker required. No inbound tunnels required. No cloud lock-in. + +--- + +## šŸš€ Why Forge? + +**Instant Agent From a Single Command** + +Write a SKILL.md. Run `forge init`. Your agent is live. + +The wizard configures your model provider, validates your API key, +connects Slack or Telegram, picks skills, and starts your agent. +Zero to running in under 5 minutes. + +### šŸ” Secure by Default + +Forge is designed for safe execution: + +* āŒ Does NOT create public tunnels +* āŒ Does NOT expose webhooks automatically +* āœ… Uses outbound-only connections (Slack Socket Mode, Telegram polling) +* āœ… Enforces outbound domain allowlists +* āœ… Supports restricted network profiles + +No accidental exposure. No hidden listeners. + +--- + +## ⚔ Get Started in 60 Seconds + +```bash +# Install +curl -sSL https://github.com/initializ/forge/releases/latest/download/forge-$(uname -s)-$(uname -m).tar.gz | tar xz +sudo mv forge /usr/local/bin/ + +# Initialize a new agent (interactive wizard) +forge init my-agent + +# Run locally +cd my-agent && forge run + +# Run with Telegram +forge run --with telegram +``` + +The `forge init` wizard walks you through model provider, API key, tools, skills, and channel setup. Use `--non-interactive` with flags for scripted setups. + +--- + +## Install + +### macOS (Homebrew) +```bash +brew install initializ/tap/forge +``` + +### Linux / macOS (binary) +```bash +curl -sSL https://github.com/initializ/forge/releases/latest/download/forge-$(uname -s)-$(uname -m).tar.gz | tar xz +sudo mv forge /usr/local/bin/ +``` + +### Windows + +Download the latest `.zip` from [GitHub Releases](https://github.com/initializ/forge/releases/latest) and add to your PATH. + +### Verify +```bash +forge --version +``` + +--- + +## How It Works + +``` +SKILL.md ─→ Parse ─→ Discover tools/requirements ─→ Compile AgentSpec + │ + v + Apply security policy + │ + v + Run LLM loop + (tool calling agent) +``` + +1. You write a `SKILL.md` that describes what the agent can do +2. Forge parses the skill definitions and optional YAML frontmatter (binary deps, env vars) +3. The build pipeline discovers tools, resolves egress domains, and compiles an `AgentSpec` +4. Security policies (egress allowlists, capability bundles) are applied +5. The runtime executes an LLM-powered tool-calling loop against your skills + +--- + +## Skills + +Skills are defined in Markdown with optional YAML frontmatter for requirements: + +```markdown +--- +name: weather +description: Weather data skill +metadata: + forge: + requires: + bins: + - curl + env: + required: [] + one_of: [] + optional: [] +--- +## Tool: weather_current + +Get current weather for a location. + +**Input:** location (string) - City name or coordinates +**Output:** Current temperature, conditions, humidity, and wind speed + +## Tool: weather_forecast + +Get weather forecast for a location. + +**Input:** location (string), days (integer: 1-7) +**Output:** Daily forecast with high/low temperatures and conditions +``` + +Each `## Tool:` heading defines a tool the agent can call. The frontmatter declares binary dependencies and environment variable requirements. Skills compile into JSON artifacts and prompt text during `forge build`. + +--- + +## Tools + +Forge ships with 7 built-in tools: + +| Tool | Description | +|------|-------------| +| `http_request` | Make HTTP requests (GET, POST, etc.) | +| `json_parse` | Parse and query JSON data | +| `csv_parse` | Parse CSV data into structured records | +| `datetime_now` | Get current date and time | +| `uuid_generate` | Generate UUID v4 identifiers | +| `math_calculate` | Evaluate mathematical expressions | +| `web_search` | Search the web using Perplexity API | + +```bash +# List all registered tools +forge tool list + +# Show details for a specific tool +forge tool describe web_search +``` + +Tools can also be added via adapters (webhook, MCP, OpenAPI) or as custom tools discovered from your project. + +--- + +## LLM Providers + +Forge supports three LLM providers out of the box: + +| Provider | Default Model | Base URL Override | +|----------|--------------|-------------------| +| `openai` | `gpt-4o` | `OPENAI_BASE_URL` | +| `anthropic` | `claude-sonnet-4-20250514` | `ANTHROPIC_BASE_URL` | +| `ollama` | `llama3` | `OLLAMA_BASE_URL` | + +Configure in `forge.yaml`: + +```yaml +model: + provider: openai + name: gpt-4o +``` + +Or override with environment variables: + +```bash +export FORGE_MODEL_PROVIDER=anthropic +export ANTHROPIC_API_KEY=sk-ant-... +forge run +``` + +Provider is auto-detected from available API keys if not explicitly set. + +--- + +## Channel Connectors + +Forge connects agents to messaging platforms via channel adapters. Both use **outbound-only connections** — no public URLs, no ngrok, no inbound webhooks. + +| Channel | Mode | How It Works | +|---------|------|-------------| +| Slack | Socket Mode | Outbound WebSocket via `apps.connections.open` | +| Telegram | Polling (default) | Long-polling via `getUpdates`, no public URL needed | + +```bash +# Add Slack adapter to your project +forge channel add slack + +# Run agent with Slack connected +forge run --with slack + +# Run with multiple channels +forge run --with slack,telegram +``` + +Channels can also run standalone as separate services: + +```bash +export AGENT_URL=http://localhost:8080 +forge channel serve slack +``` + +--- + +## šŸ” Security + +Forge generates egress security controls at build time. Every `forge build` produces an `egress_allowlist.json` and Kubernetes NetworkPolicy manifest. + +### Egress Profiles + +| Profile | Description | Default Mode | +|---------|-------------|-------------| +| `strict` | Maximum restriction, deny by default | `deny-all` | +| `standard` | Balanced, allow known domains | `allowlist` | +| `permissive` | Minimal restriction for development | `dev-open` | + +### Egress Modes + +| Mode | Behavior | +|------|----------| +| `deny-all` | No outbound network access | +| `allowlist` | Only explicitly allowed domains | +| `dev-open` | Unrestricted outbound access (development only) | + +### Configuration + +```yaml +egress: + profile: standard + mode: allowlist + allowed_domains: + - api.example.com + capabilities: + - slack +``` + +Capability bundles (e.g., `slack`, `telegram`) automatically include their required domains. Tool domains are inferred from registered tools (e.g., `web_search` adds `api.perplexity.ai`). The runtime enforces tool-level restrictions based on the compiled allowlist. + +--- + +## Running Modes + +| | `forge run` | `forge serve` | +|---|------------|--------------| +| **Purpose** | Development | Production service | +| **Channels** | `--with slack,telegram` | Reads from `forge.yaml` | +| **Sessions** | Single session | Multi-session with TTL | +| **Logging** | Human-readable | JSON structured logs | +| **Lifecycle** | Interactive | PID file, graceful shutdown | + +```bash +# Development +forge run --with slack --port 8080 + +# Production +forge serve --port 8080 --session-ttl 30m +``` + +--- + +## Packaging & Deployment + +```bash +# Build a container image (auto-detects Docker/Podman/Buildah) +forge package + +# Production build (rejects dev tools and dev-open egress) +forge package --prod + +# Build and push to registry +forge package --registry ghcr.io/myorg --push + +# Generate docker-compose with channel sidecars +forge package --with-channels + +# Export for Initializ Command platform +forge export --pretty --include-schemas +``` + +`forge package` generates a Dockerfile and Kubernetes manifests. Use `--prod` to strip dev tools and enforce strict egress. Use `--verify` to smoke-test the built container. + +--- + +## Command Reference + +| Command | Description | +|---------|-------------| +| `forge init [name]` | Initialize a new agent project (interactive wizard) | +| `forge build` | Compile agent artifacts (AgentSpec, egress allowlist, skills) | +| `forge validate [--strict] [--command-compat]` | Validate agent spec and forge.yaml | +| `forge run [--with slack,telegram] [--port 8080]` | Run agent locally with A2A dev server | +| `forge serve [--port 8080] [--session-ttl 30m]` | Run as production service | +| `forge package [--push] [--prod] [--registry] [--with-channels]` | Build container image | +| `forge export [--pretty] [--include-schemas] [--simulate-import]` | Export for Command platform | +| `forge tool list\|describe` | List or inspect registered tools | +| `forge channel add\|serve\|list\|status` | Manage channel adapters | + +See [docs/commands.md](docs/commands.md) for full flags and examples. + +--- + +## šŸ”® Upcoming + +- šŸ”Œ CrewAI / LangChain agent import +- 🧩 WASM tool plugins +- ā˜ļø One-click deploy to **initializ** +- 🧠 Persistent file-based memory +- šŸ“¦ Community skill registry + +--- + + +## šŸ’” Philosophy + + +Running agents that do real work requires more than prompts. + +It requires: + +### 🧱 Atomicity + +Agents must be packaged as clear, self-contained units: + +* Explicit skills +* Defined tools +* Declared dependencies +* Deterministic behavior + +No hidden state. No invisible glue code. + +### šŸ” Security + +Agents must run safely: + +* Restricted outbound access +* Explicit capability bundles +* No automatic inbound exposure +* Transparent execution boundaries + +If an agent can touch the outside world, it must declare how. + +### šŸ“¦ Portability + +Agents should not be locked to a framework, a cloud, or a vendor. + +A Forge agent: + +- Runs locally +- Runs in containers +- Runs in Kubernetes +- Runs in cloud +- Runs inside **initializ** +- Speaks A2A + +*Same agent. Anywhere.* + +**Forge is built on a simple belief:** + +> Real agent systems require atomicity, security, and portability. + +Forge provides those building blocks. + +--- + +## Documentation + +- [Architecture](docs/architecture.md) — System design and data flows +- [Commands](docs/commands.md) — CLI reference with all flags and examples +- [Runtime](docs/runtime.md) — LLM agent loop, providers, and memory +- [Tools](docs/tools.md) — Tool system: builtins, adapters, custom tools +- [Skills](docs/skills.md) — Skills definition and compilation +- [Security & Egress](docs/security-egress.md) — Egress security controls +- [Hooks](docs/hooks.md) — Agent loop hook system +- [Plugins](docs/plugins.md) — Framework plugin system +- [Channels](docs/channels.md) — Channel adapter architecture +- [Contributing](docs/contributing.md) — Development guide and PR process + +## License + +See [LICENSE](LICENSE) for details. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..0a46c62 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,291 @@ +# Architecture + +## Overview + +Forge is a portable runtime for building and running secure AI agents from simple skill definitions. The core data flow is: + +``` +SKILL.md → Parse → Discover tools/requirements → Compile AgentSpec → Apply security → Run LLM loop +``` + +Skill definitions and `forge.yaml` configuration are compiled into a canonical `AgentSpec`, security policies are applied, and the resulting agent can be run locally, packaged into a container, or served over the A2A protocol. + +## Module Architecture + +Forge is organized as a Go workspace with three modules: + +``` +go.work +ā”œā”€ā”€ forge-core/ Embeddable library +ā”œā”€ā”€ forge-cli/ CLI frontend +└── forge-plugins/ Channel plugin implementations +``` + +### forge-core — Library + +Pure Go library with no CLI dependencies. Provides the compiler, validator, runtime engine, LLM providers, tool/plugin/channel interfaces, A2A protocol types, and security subsystem. External consumers access the library through the `forgecore` package. + +### forge-cli — CLI Frontend + +Command-line application built on top of forge-core. Includes Cobra commands, build pipeline stages, container builders, framework plugins (CrewAI, LangChain, custom), A2A dev server, and init templates. + +### forge-plugins — Channel Plugins + +Messaging platform integrations that implement the `channels.ChannelPlugin` interface from forge-core. Ships Slack, Telegram, and markdown formatting plugins. + +## Package Map + +### forge-core + +| Package | Responsibility | Key Types | +|---------|---------------|-----------| +| `forgecore` | Public API entry point | `Compile`, `ValidateConfig`, `ValidateAgentSpec`, `NewRuntime` | +| `a2a` | A2A protocol types | `Task`, `Message`, `TaskStatus`, `Part` | +| `agentspec` | AgentSpec definitions and schema validation | `AgentSpec` | +| `channels` | Channel adapter plugin interface | `ChannelPlugin`, `ChannelConfig`, `ChannelEvent`, `EventHandler` | +| `compiler` | AgentSpec compilation and plugin config merging | `CompileRequest`, `CompileResult` | +| `export` | Agent export functionality | — | +| `llm` | LLM client interface and message types | `Client`, `ChatRequest`, `ChatResponse`, `StreamDelta` | +| `llm/providers` | LLM provider implementations | OpenAI, Anthropic, Ollama | +| `pipeline` | Build pipeline context and orchestration | `Pipeline`, `Stage`, `BuildContext` | +| `plugins` | Plugin and framework plugin interfaces | `Plugin`, `FrameworkPlugin`, `AgentConfig`, `FrameworkRegistry` | +| `registry` | Embedded skill registry | — | +| `runtime` | LLM agent loop, executor, hooks, memory, guardrails | `AgentExecutor`, `LLMExecutor`, `ToolExecutor` | +| `schemas` | Embedded JSON schemas | `agentspec.v1.0.schema.json` | +| `security` | Egress allowlist, security policies, network policies | `EgressConfig`, `Resolve`, `GenerateAllowlistJSON` | +| `skills` | Skill parsing, compilation, requirements resolution | `CompiledSkills`, `Compile`, `WriteArtifacts` | +| `tools` | Tool plugin system and executor | `Tool`, `Registry`, `CommandExecutor` | +| `tools/adapters` | Tool adapters | Webhook, MCP, OpenAPI | +| `tools/builtins` | Built-in tools | `http_request`, `json_parse`, `csv_parse`, `datetime_now`, `uuid_generate`, `math_calculate`, `web_search` | +| `types` | ForgeConfig type definitions | `ForgeConfig`, `ModelRef`, `ToolRef` | +| `util` | Utility functions | Slug generation | +| `validate` | Config and schema validation | `ValidationResult`, `ValidateForgeConfig`, `ImportSimResult` | + +### forge-cli + +| Package | Responsibility | Key Types | +|---------|---------------|-----------| +| `cmd/forge` | Main entry point | — | +| `cmd` | CLI command implementations | `init`, `build`, `run`, `validate`, `package`, `export`, `tool`, `channel`, `skills` | +| `config` | ForgeConfig loading and YAML parsing | — | +| `build` | Build pipeline stage implementations | `FrameworkAdapterStage`, `AgentSpecStage`, `ToolsStage`, `SkillsStage`, `EgressStage`, etc. | +| `container` | Container image builders | `DockerBuilder`, `PodmanBuilder`, `BuildahBuilder` | +| `plugins` | Framework plugin registry | — | +| `plugins/crewai` | CrewAI framework adapter | — | +| `plugins/langchain` | LangChain framework adapter | — | +| `plugins/custom` | Custom framework plugin | — | +| `runtime` | CLI-specific runtime (subprocess, watchers, stubs, mocks) | — | +| `server` | A2A HTTP server implementation | — | +| `channels` | Channel configuration and routing | — | +| `skills` | Skill file loading and writing | — | +| `tools` | Tool discovery and execution | — | +| `tools/devtools` | Dev-only tools | `local_shell`, `local_file_browser` | +| `templates` | Embedded templates for init wizard | — | + +### forge-plugins + +| Package | Responsibility | +|---------|---------------| +| `channels` | Channel plugin package root | +| `channels/slack` | Slack channel adapter (Socket Mode) | +| `channels/telegram` | Telegram channel adapter (polling) | +| `channels/markdown` | Markdown formatting helper | + +## Key Interfaces + +### `forgecore` Public API + +The `forgecore` package exposes the top-level library surface: + +```go +func Compile(req CompileRequest) (*CompileResult, error) +func ValidateConfig(cfg *types.ForgeConfig) *validate.ValidationResult +func ValidateAgentSpec(jsonData []byte) ([]string, error) +func ValidateCommandCompat(spec *agentspec.AgentSpec) *validate.ValidationResult +func SimulateImport(spec *agentspec.AgentSpec) *validate.ImportSimResult +func NewRuntime(cfg RuntimeConfig) *runtime.LLMExecutor +``` + +### `runtime.AgentExecutor` + +Core execution interface for running agents. Implemented by `LLMExecutor` in forge-core. + +```go +type AgentExecutor interface { + Execute(ctx context.Context, task *a2a.Task, msg *a2a.Message) (*a2a.Message, error) + ExecuteStream(ctx context.Context, task *a2a.Task, msg *a2a.Message) (<-chan *a2a.Message, error) + Close() error +} +``` + +### `llm.Client` + +Provider-agnostic LLM client. Implementations: OpenAI, Anthropic, Ollama (in `llm/providers`). + +```go +type Client interface { + Chat(ctx context.Context, req *ChatRequest) (*ChatResponse, error) + ChatStream(ctx context.Context, req *ChatRequest) (<-chan StreamDelta, error) + ModelID() string +} +``` + +### `tools.Tool` + +Agent tool with name, schema, and execution. Categories: builtin, adapter, dev, custom. + +```go +type Tool interface { + Name() string + Description() string + Category() Category + InputSchema() json.RawMessage + Execute(ctx context.Context, args json.RawMessage) (string, error) +} +``` + +### `runtime.ToolExecutor` + +Bridge between the LLM agent loop and the tool registry. + +```go +type ToolExecutor interface { + Execute(ctx context.Context, name string, arguments json.RawMessage) (string, error) + ToolDefinitions() []llm.ToolDefinition +} +``` + +### `channels.ChannelPlugin` + +Channel adapter for messaging platforms. Implementations: Slack, Telegram (in `forge-plugins/channels`). + +```go +type ChannelPlugin interface { + Name() string + Init(cfg ChannelConfig) error + Start(ctx context.Context, handler EventHandler) error + Stop() error + NormalizeEvent(raw []byte) (*ChannelEvent, error) + SendResponse(event *ChannelEvent, response *a2a.Message) error +} +``` + +### `pipeline.Stage` + +Single unit of work in the build pipeline. Receives a `BuildContext` carrying all state. + +```go +type Stage interface { + Name() string + Execute(ctx context.Context, bc *BuildContext) error +} +``` + +### `plugins.FrameworkPlugin` + +Framework adapter for the build pipeline. Implementations: CrewAI, LangChain, custom (in `forge-cli/plugins`). + +```go +type FrameworkPlugin interface { + Name() string + DetectProject(dir string) (bool, error) + ExtractAgentConfig(dir string) (*AgentConfig, error) + GenerateWrapper(config *AgentConfig) ([]byte, error) + RuntimeDependencies() []string +} +``` + +### `container.Builder` + +Container image builder. Implementations: `DockerBuilder`, `PodmanBuilder`, `BuildahBuilder` (in `forge-cli/container`). + +## Data Flows + +### Compilation Flow + +``` +forge.yaml + → config.Load() [forge-cli/config] + → types.ForgeConfig [forge-core/types] + → validate.ValidateForgeConfig() [forge-core/validate] + → skills.Compile() [forge-core/skills] + → compiler.Compile() [forge-core/compiler] + → agentspec.AgentSpec + SecurityConfig [forge-core/agentspec, forge-core/security] +``` + +Or via the public API: + +``` +forgecore.Compile(CompileRequest) → CompileResult +``` + +### Build Pipeline Flow + +The build pipeline executes stages sequentially. Each stage lives in `forge-cli/build/` and implements `pipeline.Stage` from forge-core. + +| # | Stage | Produces | +|---|-------|----------| +| 1 | **FrameworkAdapterStage** | Detects framework (crewai/langchain/custom), extracts agent config, generates A2A wrapper | +| 2 | **AgentSpecStage** | `agent.json` — canonical AgentSpec from ForgeConfig | +| 3 | **ToolsStage** | Tool schema files from discovered and configured tools | +| 4 | **PolicyStage** | `policy-scaffold.json` — guardrail configuration | +| 5 | **DockerfileStage** | `Dockerfile` — container image definition | +| 6 | **K8sStage** | `deployment.yaml`, `service.yaml`, `network-policy.yaml` | +| 7 | **ValidateStage** | Validates all generated artifacts against schemas | +| 8 | **ManifestStage** | `build-manifest.json` — build metadata and file inventory | +| — | **SkillsStage** | `compiled/skills/skills.json` + `compiled/prompt.txt` — compiled skills | +| — | **EgressStage** | `compiled/egress_allowlist.json` — egress domain allowlist | +| — | **ToolFilterStage** | Annotated + filtered tool list (dev tools removed in prod) | + +### Runtime Flow + +``` +AgentSpec + Tools + → forgecore.NewRuntime(RuntimeConfig) [forge-core/forgecore] + → runtime.LLMExecutor [forge-core/runtime] + → llm.Client (provider selection) [forge-core/llm/providers] + → Agent loop: prompt → LLM → tool calls → results → LLM → response + → a2a.Message [forge-core/a2a] +``` + +The CLI orchestrates the full runtime stack: + +``` +forge run + → config.Load() [forge-cli/config] + → tools.Discover() + tools.Registry [forge-cli/tools, forge-core/tools] + → runtime.LLMExecutor [forge-core/runtime] + → server.A2AServer [forge-cli/server] + → channels.Router (optional) [forge-cli/channels] +``` + +## Schema Validation + +AgentSpec JSON is validated against `schemas/agentspec.v1.0.schema.json` (JSON Schema draft-07) using the `gojsonschema` library. The schema is embedded in the binary via `go:embed` in `forge-core/schemas/`. + +Validation checks include: +- `agent_id` matches pattern `^[a-z0-9-]+$` +- `version` matches semver pattern +- Required fields: `forge_version`, `agent_id`, `version`, `name` +- Nested object schemas for runtime, tools, policy_scaffold, identity, a2a, model + +## Template System + +Templates use Go's `text/template` package and are embedded via `go:embed` in `forge-cli/templates/`. Templates are used for: + +- **Build output** — Dockerfile, Kubernetes manifests +- **Init scaffolding** — forge.yaml, agent entrypoints, tool examples, .gitignore +- **Framework wrappers** — A2A wrappers for CrewAI and LangChain + +## Runtime Architecture + +The local runner (`forge run`) orchestrates: + +1. **Executor selection** — `LLMExecutor` (custom with LLM) lives in forge-core; `SubprocessExecutor`, `MockExecutor`, `StubExecutor` live in `forge-cli/runtime` +2. **A2A server** — JSON-RPC 2.0 HTTP server handling `tasks/send`, `tasks/get`, `tasks/cancel` (in `forge-cli/server`) +3. **Guardrail engine** — Optional inbound/outbound message checking (in `forge-core/runtime`) +4. **Channel adapters** — Optional Slack/Telegram bridges forwarding events to the A2A server (in `forge-plugins/channels`) + +## Egress Security + +Build-time egress controls generate allowlist artifacts and Kubernetes NetworkPolicy manifests. The resolver in `forge-core/security` combines explicit domains, tool-inferred domains, and capability bundles. See [docs/security-egress.md](security-egress.md) for details. diff --git a/docs/channels.md b/docs/channels.md new file mode 100644 index 0000000..0fd5995 --- /dev/null +++ b/docs/channels.md @@ -0,0 +1,158 @@ +# Channel Adapters + +## Overview + +Channel adapters bridge messaging platforms (Slack, Telegram) to your A2A-compliant agent. Each adapter normalizes platform-specific events into a common `ChannelEvent` format, forwards them to the agent's A2A server, and delivers responses back to the originating platform. + +``` + Slack/Telegram ──→ Channel Plugin ──→ Router ──→ A2A Server + ↑ │ + └──────────────── SendResponse ā†ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +## Supported Channels + +| Channel | Adapter | Mode | Default Port | +|---------|---------|------|-------------| +| Slack | `slack.Plugin` | Socket Mode | 3000 | +| Telegram | `telegram.Plugin` | Polling or Webhook | 3001 | + +> **Note:** Slack uses Socket Mode — an outbound WebSocket connection from the agent to Slack's servers. No public URL or ngrok is needed for local development. + +## Adding a Channel + +```bash +# Add Slack adapter to your project +forge channel add slack + +# Add Telegram adapter +forge channel add telegram +``` + +This command: +1. Generates `{adapter}-config.yaml` with placeholder settings +2. Updates `.env` with required environment variables +3. Adds the channel to `forge.yaml`'s `channels` list +4. Prints setup instructions + +## Configuration + +### Slack (`slack-config.yaml`) + +```yaml +adapter: slack +settings: + app_token_env: SLACK_APP_TOKEN + bot_token_env: SLACK_BOT_TOKEN +``` + +Environment variables: +- `SLACK_APP_TOKEN` — Socket Mode app-level token (`xapp-...`) +- `SLACK_BOT_TOKEN` — Bot user OAuth token (`xoxb-...`) + +### Telegram (`telegram-config.yaml`) + +```yaml +adapter: telegram +webhook_port: 3001 +webhook_path: /telegram/webhook +settings: + bot_token: TELEGRAM_BOT_TOKEN + mode: polling +``` + +Environment variables: +- `TELEGRAM_BOT_TOKEN` — Bot token from @BotFather + +Mode options: +- `polling` (default) — Long-polling via `getUpdates` +- `webhook` — Receives updates via HTTP webhook + +## Running with Channels + +### Alongside the Agent + +```bash +# Start agent with Slack and Telegram adapters +forge run --with slack,telegram +``` + +This starts the A2A dev server and all specified channel adapters in the same process. + +### Standalone Mode + +```bash +# Run adapter separately (requires AGENT_URL) +export AGENT_URL=http://localhost:8080 +forge channel serve slack +``` + +Standalone mode is useful for running adapters as separate services in production. + +## Docker Compose Integration + +```bash +# Package agent with channel adapter sidecars +forge package --with-channels +``` + +This generates a `docker-compose.yaml` with: +- An `agent` service running the A2A server +- Adapter services (e.g., `slack-adapter`, `telegram-adapter`) connecting to the agent + +## Writing a Custom Channel Adapter + +Implement the `channels.ChannelPlugin` interface: + +```go +type ChannelPlugin interface { + // Name returns the adapter name (e.g. "slack", "telegram"). + Name() string + + // Init configures the plugin from a ChannelConfig. + Init(cfg ChannelConfig) error + + // Start begins listening for events and dispatching them to handler. + // It blocks until ctx is cancelled. + Start(ctx context.Context, handler EventHandler) error + + // Stop gracefully shuts down the plugin. + Stop() error + + // NormalizeEvent converts raw platform bytes into a ChannelEvent. + NormalizeEvent(raw []byte) (*ChannelEvent, error) + + // SendResponse delivers an A2A response back to the originating platform. + SendResponse(event *ChannelEvent, response *a2a.Message) error +} +``` + +### Key Types + +```go +// ChannelConfig holds per-adapter configuration loaded from YAML. +type ChannelConfig struct { + Adapter string `yaml:"adapter"` + WebhookPort int `yaml:"webhook_port,omitempty"` + WebhookPath string `yaml:"webhook_path,omitempty"` + Settings map[string]string `yaml:"settings,omitempty"` +} + +// ChannelEvent is the normalized representation of an inbound message. +type ChannelEvent struct { + Channel string `json:"channel"` + WorkspaceID string `json:"workspace_id"` + UserID string `json:"user_id"` + ThreadID string `json:"thread_id,omitempty"` + Message string `json:"message"` + Attachments []Attachment `json:"attachments,omitempty"` + Raw json.RawMessage `json:"raw,omitempty"` +} +``` + +### Steps + +1. Create a new package under `internal/channels/yourplatform/`. +2. Implement `ChannelPlugin`. +3. Register the plugin in the channel registry (see `internal/cmd/channel.go`). +4. Add config generation in `generateChannelConfig()` and env vars in `generateEnvVars()`. diff --git a/docs/command-integration.md b/docs/command-integration.md new file mode 100644 index 0000000..20c9a86 --- /dev/null +++ b/docs/command-integration.md @@ -0,0 +1,194 @@ +# Command Platform Integration Guide + +## Overview + +forge-core (`github.com/initializ/forge/forge-core`) is a pure Go library that Command imports to compile, validate, and run Forge agents. It has zero CLI, Docker, or Kubernetes dependencies. + +## Shared Runtime Base Image Pattern + +Unlike `forge build` which generates per-agent Dockerfiles with language-specific base images, Command uses a **shared runtime base image**: + +1. **No per-agent container builds**: Command does not run `forge build` or generate Dockerfiles. Instead, it imports agents via their AgentSpec JSON. + +2. **Shared base image**: Command maintains a single runtime base image that includes the Forge agent runtime, common language runtimes (Python, Node.js, Go), and the A2A server. Agents are loaded as configuration, not baked into containers. + +3. **Agent loading flow**: + ``` + AgentSpec JSON → forgecore.Compile() → Runtime configuration + → forgecore.NewRuntime() → LLM executor with injected tools + ``` + +4. **Why this matters**: Per-agent containers add build time, registry storage, and deployment complexity. The shared base image approach means new agents can be deployed in seconds by loading their spec, rather than minutes of container building. + +## Importing forge-core + +```go +import ( + forgecore "github.com/initializ/forge/forge-core" + "github.com/initializ/forge/forge-core/types" + "github.com/initializ/forge/forge-core/agentspec" + "github.com/initializ/forge/forge-core/skills" + "github.com/initializ/forge/forge-core/llm" + "github.com/initializ/forge/forge-core/runtime" + "github.com/initializ/forge/forge-core/security" + "github.com/initializ/forge/forge-core/tools" + "github.com/initializ/forge/forge-core/validate" +) +``` + +## Compile API + +Compile transforms a `ForgeConfig` into a fully-resolved `AgentSpec`: + +```go +result, err := forgecore.Compile(forgecore.CompileRequest{ + Config: cfg, // *types.ForgeConfig + PluginConfig: pluginCfg, // optional *plugins.AgentConfig + SkillEntries: skillEntries, // optional []skills.SkillEntry +}) +// result.Spec — *agentspec.AgentSpec +// result.CompiledSkills — *skills.CompiledSkills (nil if no skills) +// result.EgressConfig — *security.EgressConfig +// result.Allowlist — []byte (JSON) +``` + +## Validate API + +```go +// Validate forge.yaml config +valResult := forgecore.ValidateConfig(cfg) +if !valResult.IsValid() { + // handle errors +} + +// Validate agent.json against JSON schema +schemaErrs, err := forgecore.ValidateAgentSpec(jsonData) + +// Check Command platform compatibility +compatResult := forgecore.ValidateCommandCompat(spec) + +// Simulate what Command's import API would produce +simResult := forgecore.SimulateImport(spec) +``` + +## Runtime API + +Create an LLM executor with full dependency injection: + +```go +executor := forgecore.NewRuntime(forgecore.RuntimeConfig{ + LLMClient: myLLMClient, // llm.Client interface + Tools: toolRegistry, // runtime.ToolExecutor interface + Hooks: hookRegistry, // *runtime.HookRegistry (optional) + SystemPrompt: "You are ...", + MaxIterations: 10, + Guardrails: guardrailEngine, // *runtime.GuardrailEngine (optional) + Logger: logger, // runtime.Logger (optional) +}) + +// Execute a task +resp, err := executor.Execute(ctx, task, message) +``` + +## Override Patterns + +### Model Override + +Command controls which LLM provider and model each agent uses: + +```go +// Create your own LLM client with desired provider/model +client, _ := providers.NewClient("anthropic", llm.ClientConfig{ + APIKey: os.Getenv("ANTHROPIC_API_KEY"), + Model: "claude-sonnet-4-20250514", +}) +``` + +### Tool Restriction + +Command can restrict which tools are available: + +```go +// Register only approved tools +reg := tools.NewRegistry() +builtins.RegisterAll(reg) + +// Filter to allowed tools only +filtered := reg.Filter([]string{"http_request", "json_parse"}) +``` + +### Egress Tightening + +Command applies organization-level egress policies: + +```go +egressCfg, _ := security.Resolve( + "strict", // profile + "allowlist", // mode + orgAllowedDomains, // organization-level domain allowlist + toolNames, + capabilities, +) +``` + +### Skill Gating + +Skills are compiled from `[]SkillEntry`, allowing Command to filter or augment: + +```go +// Filter skills before compilation +var approved []skills.SkillEntry +for _, entry := range allEntries { + if isApproved(entry.Name) { + approved = append(approved, entry) + } +} +result, _ := forgecore.Compile(forgecore.CompileRequest{ + Config: cfg, + SkillEntries: approved, +}) +``` + +## API Stability Contract + +### Versioning + +forge-core follows semantic versioning: +- **Major version** (v1 -> v2): Breaking changes to public API signatures or behavior +- **Minor version** (v1.0 -> v1.1): New features, backward-compatible +- **Patch version** (v1.0.0 -> v1.0.1): Bug fixes only + +### Stability Guarantees + +The following are considered stable and will not change without a major version bump: + +| API | Stability | +|-----|-----------| +| `forgecore.Compile()` signature and return types | Stable | +| `forgecore.ValidateConfig()` | Stable | +| `forgecore.ValidateAgentSpec()` | Stable | +| `forgecore.ValidateCommandCompat()` | Stable | +| `forgecore.SimulateImport()` | Stable | +| `forgecore.NewRuntime()` | Stable | +| `types.ForgeConfig` struct fields | Stable | +| `agentspec.AgentSpec` struct fields | Stable | +| `llm.Client` interface | Stable | +| `runtime.ToolExecutor` interface | Stable | +| `runtime.Logger` interface | Stable | +| `security.EgressConfig` struct | Stable | +| `skills.SkillEntry` struct | Stable | +| `tools.Registry` public methods | Stable | + +### Supported Versions + +| Field | Supported Values | +|-------|-----------------| +| `forge_version` | `1.0`, `1.1` | +| `tool_interface_version` | `1.0` | +| `skills_spec_version` | `agentskills-v1` | + +### Deprecation Policy + +- Deprecated APIs will be marked with `// Deprecated:` comments +- Deprecated APIs will continue to work for at least one minor version +- Removal of deprecated APIs requires a major version bump diff --git a/docs/commands.md b/docs/commands.md new file mode 100644 index 0000000..4ff3e18 --- /dev/null +++ b/docs/commands.md @@ -0,0 +1,317 @@ +# CLI Reference + +## Global Flags + +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `--config` | | `forge.yaml` | Config file path | +| `--verbose` | `-v` | `false` | Enable verbose output | +| `--output-dir` | `-o` | `.` | Output directory | + +--- + +## `forge init` + +Initialize a new agent project. + +``` +forge init [name] [flags] +``` + +### Flags + +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `--name` | `-n` | | Agent name | +| `--framework` | `-f` | | Framework: `crewai`, `langchain`, or `custom` | +| `--language` | `-l` | | Language: `python`, `typescript`, or `go` | +| `--model-provider` | `-m` | | Model provider: `openai`, `anthropic`, `ollama`, or `custom` | +| `--channels` | | | Channel adapters (e.g., `slack,telegram`) | +| `--tools` | | | Builtin tools to enable (e.g., `web_search,http_request`) | +| `--skills` | | | Registry skills to include (e.g., `github,weather`) | +| `--api-key` | | | LLM provider API key | +| `--from-skills` | | | Path to a skills.md file for auto-configuration | +| `--non-interactive` | | `false` | Skip interactive prompts | + +### Examples + +```bash +# Interactive mode (default) +forge init my-agent + +# Non-interactive with all options +forge init my-agent \ + --framework langchain \ + --language python \ + --model-provider openai \ + --channels slack,telegram \ + --non-interactive + +# From a skills file +forge init my-agent --from-skills skills.md + +# With builtin tools and registry skills +forge init my-agent \ + --framework custom \ + --model-provider openai \ + --tools web_search,http_request \ + --skills github \ + --api-key sk-... \ + --non-interactive +``` + +--- + +## `forge build` + +Build the agent container artifact. Runs the full 8-stage build pipeline. + +``` +forge build [flags] +``` + +Uses global `--config` and `--output-dir` flags. Output is written to `.forge-output/` by default. + +### Examples + +```bash +# Build with default config +forge build + +# Build with custom config and output +forge build --config agent.yaml --output-dir ./build +``` + +--- + +## `forge validate` + +Validate agent spec and forge.yaml. + +``` +forge validate [flags] +``` + +### Flags + +| Flag | Default | Description | +|------|---------|-------------| +| `--strict` | `false` | Treat warnings as errors | +| `--command-compat` | `false` | Check Command platform import compatibility | + +### Examples + +```bash +# Basic validation +forge validate + +# Strict mode +forge validate --strict + +# Check Command compatibility +forge validate --command-compat +``` + +--- + +## `forge run` + +Run the agent locally with an A2A-compliant dev server. + +``` +forge run [flags] +``` + +### Flags + +| Flag | Default | Description | +|------|---------|-------------| +| `--port` | `8080` | Port for the A2A dev server | +| `--mock-tools` | `false` | Use mock runtime instead of subprocess | +| `--enforce-guardrails` | `false` | Enforce guardrail violations as errors | +| `--model` | | Override model name (sets `MODEL_NAME` env var) | +| `--provider` | | LLM provider: `openai`, `anthropic`, or `ollama` | +| `--env` | `.env` | Path to .env file | +| `--with` | | Comma-separated channel adapters (e.g., `slack,telegram`) | + +### Examples + +```bash +# Run with defaults +forge run + +# Run with mock tools on custom port +forge run --port 9090 --mock-tools + +# Run with LLM provider and channels +forge run --provider openai --model gpt-4 --with slack + +# Run with guardrails enforced +forge run --enforce-guardrails --env .env.production +``` + +--- + +## `forge export` + +Export agent spec for Command platform import. + +``` +forge export [flags] +``` + +### Flags + +| Flag | Default | Description | +|------|---------|-------------| +| `--output` | `{agent_id}-forge.json` | Output file path | +| `--pretty` | `false` | Format JSON with indentation | +| `--include-schemas` | `false` | Embed tool schemas inline | +| `--simulate-import` | `false` | Print simulated import result | +| `--dev` | `false` | Include dev-category tools in export | + +### Examples + +```bash +# Export with defaults +forge export + +# Pretty-print with embedded schemas +forge export --pretty --include-schemas + +# Simulate Command import +forge export --simulate-import +``` + +--- + +## `forge package` + +Build a container image for the agent. + +``` +forge package [flags] +``` + +### Flags + +| Flag | Default | Description | +|------|---------|-------------| +| `--push` | `false` | Push image to registry after building | +| `--platform` | | Target platform (e.g., `linux/amd64`) | +| `--no-cache` | `false` | Disable layer cache | +| `--dev` | `false` | Include dev tools in image | +| `--prod` | `false` | Production build (rejects dev tools and dev-open egress) | +| `--verify` | `false` | Smoke-test container after build | +| `--registry` | | Registry prefix (e.g., `ghcr.io/org`) | +| `--builder` | | Force builder: `docker`, `podman`, or `buildah` | +| `--skip-build` | `false` | Skip re-running forge build | +| `--with-channels` | `false` | Generate docker-compose.yaml with channel adapters | + +### Examples + +```bash +# Build image with auto-detected builder +forge package + +# Build and push to registry +forge package --registry ghcr.io/myorg --push + +# Build for specific platform with no cache +forge package --platform linux/amd64 --no-cache + +# Generate docker-compose with channels +forge package --with-channels +``` + +--- + +## `forge tool` + +Manage and inspect agent tools. + +### `forge tool list` + +List all available tools. + +```bash +forge tool list +``` + +### `forge tool describe` + +Show tool details and input schema. + +```bash +forge tool describe +``` + +### Examples + +```bash +# List all tools +forge tool list + +# Describe a specific tool +forge tool describe web-search +``` + +--- + +## `forge channel` + +Manage agent communication channels. + +### `forge channel add` + +Add a channel adapter to the project. + +```bash +forge channel add +``` + +### `forge channel serve` + +Run a standalone channel adapter. + +```bash +forge channel serve +``` + +Requires the `AGENT_URL` environment variable to be set. + +### `forge channel list` + +List available channel adapters. + +```bash +forge channel list +``` + +### `forge channel status` + +Show configured channels from `forge.yaml`. + +```bash +forge channel status +``` + +### Examples + +```bash +# Add Slack adapter +forge channel add slack + +# Add Telegram adapter +forge channel add telegram + +# List available adapters +forge channel list + +# Show configured channels +forge channel status + +# Run Slack adapter standalone +export AGENT_URL=http://localhost:8080 +forge channel serve slack +``` diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..eeb3a63 --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,156 @@ +# Contributing + +## Development Setup + +### Prerequisites + +- Go 1.25 or later +- `golangci-lint` (for linting) +- `goreleaser` (for releases, optional) + +### Clone and Build + +```bash +git clone https://github.com/initializ/forge.git +cd forge +make build +``` + +### Verify + +```bash +make vet +make test +``` + +## Code Organization + +``` +internal/cmd/ # CLI command definitions (one file per command) +internal/config/ # Configuration parsing +internal/models/ # Data structures (AgentSpec, ToolSpec, etc.) +internal/pipeline/ # Build pipeline engine +internal/build/ # Build stage implementations +internal/plugins/ # Framework plugin system +internal/runtime/ # Agent local runner +internal/runtime/llm # LLM client abstraction +internal/container/ # Container image building +internal/channels/ # Channel adapter system +internal/tools/ # Tool registry and implementations +internal/validate/ # Validation logic +pkg/a2a/ # Shared A2A protocol types +schemas/ # Embedded JSON schemas +templates/ # Go templates for code generation +testdata/ # Test fixtures +``` + +## How to Add... + +### A New Command + +1. Create `internal/cmd/yourcommand.go` +2. Define a `cobra.Command` variable +3. Register it in `internal/cmd/root.go`'s `init()` function +4. Add tests in `internal/cmd/yourcommand_test.go` + +### A New Build Stage + +1. Create `internal/build/yourstage.go` +2. Implement the `pipeline.Stage` interface (`Name()` and `Execute()`) +3. Add the stage to the pipeline in `internal/cmd/build.go` +4. Add tests in `internal/build/yourstage_test.go` + +### A New Tool + +**Builtin tool:** +1. Create `internal/tools/builtins/your_tool.go` +2. Implement the `tools.Tool` interface +3. Register in `internal/tools/builtins/register.go`'s `RegisterAll()` function + +**Custom tool (script-based):** +1. Create `tools/tool_yourname.py` (or `.ts`/`.js`) in the agent project +2. Forge discovers it automatically via `tools.DiscoverTools()` + +### A New Channel Adapter + +1. Create `internal/channels/yourplatform/yourplatform.go` +2. Implement the `channels.ChannelPlugin` interface +3. Register in `internal/cmd/channel.go`'s `createPlugin()` and `defaultRegistry()` +4. Add config generation in `generateChannelConfig()` and `generateEnvVars()` +5. Add tests + +### A New LLM Provider + +1. Create `internal/runtime/llm/providers/yourprovider.go` +2. Implement the `llm.Client` interface (`Chat()`, `ChatStream()`, `ModelID()`) +3. Add the provider to the factory in `internal/runtime/llm/providers/factory.go` +4. Add tests + +## Testing Guidelines + +### Unit Tests + +- Every package should have `*_test.go` files +- Test files use the same package name (white-box) or `_test` suffix (black-box) +- Use table-driven tests where appropriate +- Mock external dependencies (HTTP servers, file system, etc.) + +### Integration Tests + +- Use the `//go:build integration` build tag +- Run with `go test -tags=integration ./...` +- Integration tests may use test fixtures from `testdata/` +- No external services required (use `httptest` for mock servers) + +### Test Fixtures + +Test fixtures live in `testdata/` at the project root: +- `forge-valid.yaml` — Full valid forge.yaml +- `forge-minimal.yaml` — Bare-minimum valid config +- `forge-invalid.yaml` — Invalid config for error testing +- `agentspec-valid.json` — Valid AgentSpec JSON +- `agentspec-invalid.json` — Invalid AgentSpec for error testing +- `tool-schema.json` — Minimal tool input schema + +### Running Tests + +```bash +# All unit tests +make test + +# Integration tests +make test-integration + +# Coverage report +make cover + +# Specific package +go test -v ./internal/pipeline/... +``` + +## Code Style + +- Run `go fmt` on all files +- Run `go vet` before committing +- Run `golangci-lint run ./...` for additional checks +- Keep functions focused and small +- Use meaningful variable names +- Add comments for non-obvious logic only + +## PR Process + +1. Create a feature branch from `develop` +2. Make your changes with tests +3. Ensure all checks pass: `make vet && make test && make fmt` +4. Push and open a pull request against `develop` +5. PRs require passing CI checks before merge + +## Release Process + +Releases are automated via GoReleaser: + +1. Ensure `develop` is stable and all tests pass +2. Merge `develop` into `main` +3. Tag the release: `git tag v0.1.0` +4. Push the tag: `git push origin v0.1.0` +5. GitHub Actions runs GoReleaser to build and publish binaries diff --git a/docs/hooks.md b/docs/hooks.md new file mode 100644 index 0000000..5468ae0 --- /dev/null +++ b/docs/hooks.md @@ -0,0 +1,100 @@ +# Hooks + +The hook system allows custom logic to run at key points in the LLM agent loop. Hooks can observe, modify context, or block execution. + +## Overview + +Hooks are defined in `internal/runtime/engine/hooks.go`. They fire synchronously during the agent loop and can: + +- **Log** interactions for debugging or auditing +- **Block** execution by returning an error +- **Inspect** messages, responses, and tool activity + +## Hook Points + +| Hook Point | When It Fires | HookContext Data | +|-----------|---------------|------------------| +| `BeforeLLMCall` | Before each LLM API call | `Messages` | +| `AfterLLMCall` | After each LLM API call | `Messages`, `Response` | +| `BeforeToolExec` | Before each tool execution | `ToolName`, `ToolInput` | +| `AfterToolExec` | After each tool execution | `ToolName`, `ToolInput`, `ToolOutput`, `Error` | +| `OnError` | When an LLM call fails | `Error` | + +## HookContext + +The `HookContext` struct carries data available at each hook point: + +```go +type HookContext struct { + Messages []llm.ChatMessage // Current conversation messages + Response *llm.ChatResponse // LLM response (AfterLLMCall only) + ToolName string // Tool being executed + ToolInput string // Tool input arguments (JSON) + ToolOutput string // Tool result (AfterToolExec only) + Error error // Error that occurred +} +``` + +## Writing Hooks + +Hooks implement the `Hook` function signature: + +```go +type Hook func(ctx context.Context, hctx *HookContext) error +``` + +### Logging Hook Example + +```go +hooks := engine.NewHookRegistry() + +hooks.Register(engine.BeforeLLMCall, func(ctx context.Context, hctx *engine.HookContext) error { + log.Printf("LLM call with %d messages", len(hctx.Messages)) + return nil +}) + +hooks.Register(engine.AfterToolExec, func(ctx context.Context, hctx *engine.HookContext) error { + log.Printf("Tool %s returned: %s", hctx.ToolName, hctx.ToolOutput) + return nil +}) +``` + +### Enforcement Hook Example + +```go +hooks.Register(engine.BeforeToolExec, func(ctx context.Context, hctx *engine.HookContext) error { + if hctx.ToolName == "dangerous_tool" { + return fmt.Errorf("tool %q is blocked by policy", hctx.ToolName) + } + return nil +}) +``` + +## Error Handling + +- Hooks fire **in registration order** for each hook point +- If a hook returns an **error**, execution stops immediately +- The error propagates up to the `Execute` caller +- For `BeforeToolExec`, returning an error prevents the tool from running +- For `OnError`, the error from the LLM call is available in `hctx.Error` + +## Registration + +```go +hooks := engine.NewHookRegistry() +hooks.Register(engine.BeforeLLMCall, myHook) +hooks.Register(engine.AfterToolExec, myOtherHook) + +exec := engine.NewLLMExecutor(engine.LLMExecutorConfig{ + Client: client, + Tools: tools, + Hooks: hooks, +}) +``` + +If no `HookRegistry` is provided, an empty one is created automatically. + +## Related Files + +- `internal/runtime/engine/hooks.go` — Hook types, registry, and firing logic +- `internal/runtime/engine/loop.go` — Hook integration in the agent loop diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 0000000..4ebe1be --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,141 @@ +# Framework Plugins + +## Overview + +Forge uses a plugin system to support multiple AI agent frameworks. Each framework plugin adapts a specific framework (CrewAI, LangChain, or custom) to the Forge build pipeline, handling project detection, configuration extraction, and A2A wrapper generation. + +## Supported Frameworks + +| Framework | Plugin | Languages | Wrapper | +|-----------|--------|-----------|---------| +| CrewAI | `crewai.Plugin` | Python | `crewai_wrapper.py` | +| LangChain | `langchain.Plugin` | Python | `langchain_wrapper.py` | +| Custom | `custom.Plugin` | Python, TypeScript, Go | None (agent is the wrapper) | + +## Plugin Interface + +Every framework plugin implements `plugins.FrameworkPlugin`: + +```go +type FrameworkPlugin interface { + // Name returns the framework name (e.g. "crewai", "langchain", "custom"). + Name() string + + // DetectProject checks if a directory contains this framework's project. + DetectProject(dir string) (bool, error) + + // ExtractAgentConfig reads framework-specific files and returns + // an intermediate AgentConfig representation. + ExtractAgentConfig(dir string) (*AgentConfig, error) + + // GenerateWrapper produces an A2A wrapper script for the framework. + // Returns nil if no wrapper is needed. + GenerateWrapper(config *AgentConfig) ([]byte, error) + + // RuntimeDependencies returns pip/npm packages the framework needs at runtime. + RuntimeDependencies() []string +} +``` + +## How Plugins Work in the Build Pipeline + +1. **Detection** — The `FrameworkAdapterStage` checks the `framework` field in `forge.yaml`. If set, it looks up the plugin by name. Otherwise, it calls `DetectProject()` on each registered plugin to auto-detect the framework. + +2. **Extraction** — `ExtractAgentConfig()` reads framework-specific source files (e.g., CrewAI agent definitions, LangChain chain configurations) and produces an `AgentConfig` struct: + + ```go + type AgentConfig struct { + Name string + Description string + Tools []ToolDefinition + Identity *IdentityConfig + Model *PluginModelConfig + Extra map[string]any + } + ``` + +3. **Wrapper Generation** — `GenerateWrapper()` produces an A2A-compliant HTTP server wrapper that launches the framework agent and translates between A2A protocol messages and framework-native calls. + +4. **Output** — The wrapper is written to the build output directory and referenced in the Dockerfile entrypoint. + +## Writing a Custom Plugin + +To add support for a new framework: + +1. Create a new package under `internal/plugins/yourframework/`. + +2. Implement the `FrameworkPlugin` interface: + + ```go + package yourframework + + import "github.com/initializ/forge/internal/plugins" + + type Plugin struct{} + + func (p *Plugin) Name() string { return "yourframework" } + + func (p *Plugin) DetectProject(dir string) (bool, error) { + // Check for framework-specific files + // e.g., a config file or specific imports + return false, nil + } + + func (p *Plugin) ExtractAgentConfig(dir string) (*plugins.AgentConfig, error) { + // Parse source files and extract agent configuration + return &plugins.AgentConfig{ + Name: "my-agent", + Description: "Agent built with YourFramework", + }, nil + } + + func (p *Plugin) GenerateWrapper(config *plugins.AgentConfig) ([]byte, error) { + // Generate A2A wrapper code or return nil if not needed + return nil, nil + } + + func (p *Plugin) RuntimeDependencies() []string { + return []string{"yourframework>=1.0"} + } + ``` + +3. Register the plugin in `internal/cmd/build.go`: + + ```go + reg.Register(&yourframework.Plugin{}) + ``` + +4. Add a wrapper template in `templates/` if needed. + +## Plugin Configuration + +The `AgentConfig` struct is the intermediate representation passed between the framework plugin and subsequent build stages: + +```go +type AgentConfig struct { + Name string // Agent display name + Description string // Agent description + Tools []ToolDefinition // Tools discovered from source + Identity *IdentityConfig // Agent identity (role, goal, backstory) + Model *PluginModelConfig // Model info from source + Extra map[string]any // Framework-specific extra data +} +``` + +The `FrameworkAdapterStage` stores this in `BuildContext.PluginConfig`, making it available to all subsequent stages. + +## Hook System + +Forge also has a general-purpose plugin hook system for extending the build lifecycle: + +```go +type Plugin interface { + Name() string + Version() string + Init(config map[string]any) error + Hooks() []HookPoint + Execute(ctx context.Context, hook HookPoint, data map[string]any) error +} +``` + +Available hook points: `pre-build`, `post-build`, `pre-push`, `post-push`. diff --git a/docs/runtime.md b/docs/runtime.md new file mode 100644 index 0000000..77e681e --- /dev/null +++ b/docs/runtime.md @@ -0,0 +1,89 @@ +# LLM Runtime Engine + +The runtime engine powers `forge run` — executing agent tasks via LLM providers with tool calling, conversation memory, and lifecycle hooks. + +## Agent Loop + +The core agent loop is implemented in `internal/runtime/engine/loop.go`. It follows a simple pattern: + +1. **Initialize memory** with the system prompt and task history +2. **Append** the user message +3. **Call the LLM** with the conversation and available tool definitions +4. If the LLM returns **tool calls**: execute each tool, append results, go to step 3 +5. If the LLM returns a **text response**: return it as the final answer +6. If **max iterations** are exceeded: return an error + +``` +User message → Memory → LLM → tool_calls? → Execute tools → LLM → ... → text → Done +``` + +The loop terminates when `FinishReason == "stop"` or `len(ToolCalls) == 0`. + +## Executor Types + +The runtime supports multiple executor implementations: + +| Executor | Use Case | +|----------|----------| +| `LLMExecutor` | Custom agents with LLM-powered tool calling | +| `SubprocessExecutor` | Framework agents (CrewAI, LangChain) running as subprocesses | +| `StubExecutor` | Returns canned responses for testing | + +Executor selection happens in `internal/runtime/runner.go` based on framework type and configuration. + +## Provider Configuration + +Provider configuration is resolved in `internal/runtime/engine/config.go` via `ResolveModelConfig()`. Sources are checked in priority order: + +1. **CLI flag** `--provider` (highest priority) +2. **Environment variables**: `FORGE_MODEL_PROVIDER`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `LLM_API_KEY` +3. **forge.yaml** `model` section (lowest priority) + +If no provider is explicitly set, the system auto-detects from available API keys. + +### Supported Providers + +| Provider | Default Model | Base URL Override | +|----------|--------------|-------------------| +| `openai` | `gpt-4o` | `OPENAI_BASE_URL` | +| `anthropic` | `claude-sonnet-4-20250514` | `ANTHROPIC_BASE_URL` | +| `ollama` | `llama3` | `OLLAMA_BASE_URL` | + +All providers implement the `llm.Client` interface defined in `internal/runtime/llm/client.go`: + +```go +type Client interface { + Chat(ctx context.Context, req *ChatRequest) (*ChatResponse, error) + ChatStream(ctx context.Context, req *ChatRequest) (<-chan StreamDelta, error) + ModelID() string +} +``` + +## Conversation Memory + +Memory management is handled by `internal/runtime/engine/memory.go`. Key behaviors: + +- **System prompt** is always prepended to the message list (never trimmed) +- **Character budget** defaults to 32,000 characters (~8,000 tokens) +- When over budget, **oldest messages are trimmed first** +- The **most recent message is never trimmed** +- Memory is per-task (created fresh for each `Execute` call) +- Thread-safe via `sync.Mutex` + +## Streaming + +The current implementation (v1) runs the full tool-calling loop non-streaming. `ExecuteStream` calls `Execute` internally and emits the final response as a single message on a channel. True word-by-word streaming during tool loops is planned for v2. + +## Hooks + +The engine fires hooks at key points in the loop. See [docs/hooks.md](hooks.md) for details. + +## Related Files + +- `internal/runtime/engine/loop.go` — Agent loop implementation +- `internal/runtime/engine/memory.go` — Conversation memory +- `internal/runtime/engine/config.go` — Provider configuration resolution +- `internal/runtime/engine/hooks.go` — Hook system +- `internal/runtime/llm/client.go` — LLM client interface +- `internal/runtime/llm/types.go` — Canonical chat types +- `internal/runtime/llm/providers/` — Provider implementations diff --git a/docs/security-egress.md b/docs/security-egress.md new file mode 100644 index 0000000..dd27863 --- /dev/null +++ b/docs/security-egress.md @@ -0,0 +1,138 @@ +# Egress Security + +Forge provides build-time egress security controls that restrict which external domains a containerized agent can access. Egress configuration generates allowlist artifacts and Kubernetes NetworkPolicy manifests. + +## Overview + +Egress security operates at **build time only** — it generates configuration files that are enforced by the container runtime (Kubernetes NetworkPolicy). There is no runtime egress guard in the agent process itself. + +The system resolves allowed domains from three sources: +1. **Explicit domains** — Listed in `forge.yaml` +2. **Tool domains** — Inferred from registered tools +3. **Capability bundles** — Pre-defined domain sets for common services + +## Profiles + +Profiles set the overall security posture. Defined in `internal/security/egress/types.go`: + +| Profile | Description | Default Mode | +|---------|-------------|-------------| +| `strict` | Maximum restriction, deny by default | `deny-all` | +| `standard` | Balanced, allow known domains | `allowlist` | +| `permissive` | Minimal restriction for development | `dev-open` | + +Default profile: `strict`. Default mode: `deny-all`. + +## Modes + +Modes control egress behavior within a profile: + +| Mode | Behavior | +|------|----------| +| `deny-all` | No outbound network access | +| `allowlist` | Only explicitly allowed domains | +| `dev-open` | Unrestricted outbound access (development only) | + +## Capability Bundles + +Capability bundles (`internal/security/egress/capabilities.go`) map service names to their required domains: + +| Capability | Domains | +|-----------|---------| +| `slack` | `slack.com`, `hooks.slack.com`, `api.slack.com` | +| `telegram` | `api.telegram.org` | + +Specify capabilities in `forge.yaml` to automatically include their domains. + +## Tool Domain Inference + +The tool domain inference system (`internal/security/egress/tool_domains.go`) maps tool names to known required domains: + +| Tool | Inferred Domains | +|------|-----------------| +| `web_search` | `api.perplexity.ai` | +| `github_api` | `api.github.com`, `github.com` | +| `slack_notify` | `slack.com`, `hooks.slack.com` | +| `openai_completion` | `api.openai.com` | +| `anthropic_api` | `api.anthropic.com` | +| `huggingface_api` | `api-inference.huggingface.co`, `huggingface.co` | +| `sendgrid_email` | `api.sendgrid.com` | +| `twilio_sms` | `api.twilio.com` | +| `aws_bedrock` | `bedrock-runtime.us-east-1.amazonaws.com` | +| `azure_openai` | `openai.azure.com` | + +## Allowlist Resolution + +The resolver (`internal/security/egress/resolver.go`) combines all domain sources: + +1. Validate profile and mode +2. For `deny-all`: return empty config +3. For `dev-open`: return unrestricted config +4. For `allowlist`: + - Start with explicit domains from `forge.yaml` + - Add tool-inferred domains + - Add capability bundle domains + - Deduplicate and sort + +## Build Artifacts + +The `EgressStage` (`internal/build/egress_stage.go`) generates: + +### `egress_allowlist.json` + +```json +{ + "profile": "standard", + "mode": "allowlist", + "allowed_domains": ["api.example.com"], + "tool_domains": ["googleapis.com"], + "all_domains": ["api.example.com", "googleapis.com"] +} +``` + +Empty arrays are always `[]`, never `null`. + +### Kubernetes `network-policy.yaml` + +Generated by `GenerateK8sNetworkPolicy()` in `internal/security/egress/network_policy.go`: + +- **deny-all**: Empty egress rules (`egress: []`) +- **allowlist**: Allows ports 80/443 with domain annotations +- **dev-open**: Allows ports 80/443 without restrictions + +The NetworkPolicy uses pod selector `app: ` and includes domain annotations for external DNS-based policy controllers. + +## Configuration + +In `forge.yaml`: + +```yaml +egress: + profile: standard + mode: allowlist + allowed_domains: + - api.example.com + - hooks.slack.com + capabilities: + - slack + - telegram +``` + +## Production vs Development + +| Setting | Production | Development | +|---------|-----------|-------------| +| Profile | `strict` or `standard` | `permissive` | +| Mode | `deny-all` or `allowlist` | `dev-open` | +| Dev tools | Filtered out | Included | +| Network policy | Enforced | Not generated | + +## Related Files + +- `internal/security/egress/types.go` — Profile and mode types +- `internal/security/egress/resolver.go` — Allowlist resolution logic +- `internal/security/egress/capabilities.go` — Capability bundle definitions +- `internal/security/egress/tool_domains.go` — Tool domain inference +- `internal/security/egress/allowlist.go` — JSON allowlist generation +- `internal/security/egress/network_policy.go` — K8s NetworkPolicy generation +- `internal/build/egress_stage.go` — Build pipeline integration diff --git a/docs/skills.md b/docs/skills.md new file mode 100644 index 0000000..ef4b709 --- /dev/null +++ b/docs/skills.md @@ -0,0 +1,126 @@ +# Skills + +Skills are a progressive disclosure mechanism for defining agent capabilities in a structured, human-readable format. They compile into container artifacts during `forge build`. + +## Overview + +Skills bridge the gap between high-level capability descriptions and the tool-calling system. A `skills.md` file in your project root defines what the agent can do, and Forge compiles these into JSON artifacts and prompt text for the container. + +## SKILL.md Format + +Skills are defined in a Markdown file (default: `skills.md`). The file supports optional YAML frontmatter and two body formats. + +### YAML Frontmatter + +Skills can declare metadata and requirements in a YAML frontmatter block delimited by `---`: + +```markdown +--- +name: weather +description: Weather data skill +metadata: + forge: + requires: + bins: + - curl + env: + required: [] + one_of: [] + optional: [] +--- +## Tool: weather_current +Get current weather for a location. +``` + +The `metadata.forge.requires` block declares: +- **`bins`** — Binary dependencies that must be in `$PATH` at runtime +- **`env.required`** — Environment variables that must be set +- **`env.one_of`** — At least one of these environment variables must be set +- **`env.optional`** — Optional environment variables for extended functionality + +Frontmatter is parsed by `ParseWithMetadata()` in `forge-core/skills/parser.go` and feeds into the compilation pipeline. The `SkillMetadata` and `SkillRequirements` types are defined in `forge-core/skills/types.go`. + +### Tool Heading Format (recommended) + +```markdown +## Tool: web_search +Search the web for current information and return relevant results. + +**Input:** query: string, max_results: int +**Output:** results: []string + +## Tool: summarize +Summarize long text into a concise paragraph. + +**Input:** text: string, max_length: int +**Output:** summary: string +``` + +Each `## Tool:` heading starts a new skill entry. Paragraph text becomes the description. `**Input:**` and `**Output:**` lines set the input/output specifications. + +### Legacy List Format + +```markdown +# Agent Skills + +- translate +- summarize +- classify +``` + +Single-word list items (no spaces, max 64 characters) create name-only skill entries. This format is simpler but provides less metadata. + +## Compilation Pipeline + +The skill compilation pipeline has three stages: + +1. **Parse** (`internal/plugins/skills/parser.go`) — Reads `skills.md` and extracts `SkillEntry` values with name, description, input spec, and output spec. When YAML frontmatter is present, `ParseWithMetadata()` (`forge-core/skills/parser.go`) additionally extracts `SkillMetadata` and `SkillRequirements` (binary deps, env vars). + +2. **Compile** (`internal/skills/compiler.go`) — Converts entries into `CompiledSkills` with: + - A JSON-serializable skill list + - A human-readable prompt catalog + - Version identifier (`agentskills-v1`) + +3. **Write Artifacts** — Outputs to the build directory: + - `compiled/skills/skills.json` — Machine-readable skill definitions + - `compiled/prompt.txt` — LLM-readable skill catalog + +## Build Stage Integration + +The `SkillsStage` (`internal/build/skills_stage.go`) runs as part of the build pipeline: + +1. Resolves the skills file path (default: `skills.md` in work directory) +2. Skips silently if the file doesn't exist +3. Parses, compiles, and writes artifacts +4. Updates the `AgentSpec` with `skills_spec_version` and `forge_skills_ext_version` +5. Records generated files in the build manifest + +## Prompt-Only vs Tool-Bearing Skills + +- **Prompt-only skills** (legacy format) provide names only. They appear in the prompt catalog but have no structured input/output. +- **Tool-bearing skills** (heading format) include full specifications that can be used for validation and documentation. + +## Configuration + +In `forge.yaml`: + +```yaml +skills: + path: skills.md # default, can be customized +``` + +## CLI Workflow + +```bash +# Initialize a project with skills support +forge init my-agent --from-skills + +# Build compiles skills automatically +forge build +``` + +## Related Files + +- `internal/plugins/skills/parser.go` — SKILL.md parser +- `internal/skills/compiler.go` — Skill compilation and artifact generation +- `internal/build/skills_stage.go` — Build pipeline integration diff --git a/docs/tools.md b/docs/tools.md new file mode 100644 index 0000000..71427d3 --- /dev/null +++ b/docs/tools.md @@ -0,0 +1,134 @@ +# Tools + +Tools are capabilities that an LLM agent can invoke during execution. Forge provides a pluggable tool system with built-in tools, adapter tools, development tools, and custom tools. + +## Tool Categories + +| Category | Code | Description | +|----------|------|-------------| +| **Builtin** | `builtin` | Core tools shipped with Forge (A) | +| **Adapter** | `adapter` | External service integrations via webhook, MCP, or OpenAPI (B) | +| **Dev** | `dev` | Development-only tools, filtered in production builds (C) | +| **Custom** | `custom` | User-defined tools discovered from the project | + +## Tool Interface + +All tools implement the `tools.Tool` interface defined in `internal/tools/tool.go`: + +```go +type Tool interface { + Name() string + Description() string + Category() Category + InputSchema() json.RawMessage + Execute(ctx context.Context, args json.RawMessage) (string, error) +} +``` + +## Built-in Tools + +Located in `internal/tools/builtins/`: + +| Tool | Description | +|------|-------------| +| `web_search` | Search the web using Perplexity API | +| `http_request` | Make HTTP requests (GET, POST, etc.) | +| `json_parse` | Parse and query JSON data | +| `csv_parse` | Parse CSV data into structured records | +| `datetime_now` | Get current date and time | +| `uuid_generate` | Generate UUID v4 identifiers | +| `math_calculate` | Evaluate mathematical expressions | + +Register all builtins with `builtins.RegisterAll(registry)`. + +## Adapter Tools + +Located in `internal/tools/adapters/`: + +| Adapter | Description | +|---------|-------------| +| `webhook` | Invoke external HTTP endpoints as tools | +| `mcp` | Connect to Model Context Protocol servers | +| `openapi` | Auto-generate tools from OpenAPI specifications | + +Adapter tools bridge external services into the agent's tool set. + +## Development Tools + +Located in `internal/tools/devtools/`: + +Development tools (`local_shell`, `local_file_browser`, `debug_console`, `test_runner`) are available during `forge run --dev` but are **automatically filtered out** in production builds by the `ToolFilterStage`. + +## Writing a Custom Tool + +Custom tools are discovered from the project directory. Create a Python or TypeScript file with a docstring schema: + +```python +""" +Tool: my_custom_tool +Description: Does something useful. + +Input: + query (str): The search query. + limit (int): Maximum results. + +Output: + results (list): The search results. +""" + +import json +import sys + +def execute(args: dict) -> str: + query = args.get("query", "") + return json.dumps({"results": [f"Result for: {query}"]}) + +if __name__ == "__main__": + input_data = json.loads(sys.stdin.read()) + print(execute(input_data)) +``` + +## Tool Discovery + +The tool discovery system (`internal/tools/discovery.go`) scans project directories for custom tool files. It recognizes: + +- Python files with docstring schemas +- TypeScript files with JSDoc schemas +- Tool configuration in `forge.yaml` + +## Tool Registry + +The `tools.Registry` (`internal/tools/registry.go`) is a thread-safe tool registry that: + +- Prevents duplicate registrations +- Provides `Execute(name, args)` and `ToolDefinitions()` methods +- Satisfies the `engine.ToolExecutor` interface via structural typing + +## CLI Commands + +```bash +# List all registered tools +forge tool list + +# Show details for a specific tool +forge tool describe web_search +``` + +## Build Pipeline + +The `ToolFilterStage` (`internal/build/tool_filter_stage.go`) runs during `forge build`: + +1. Annotates each tool with its category (builtin, adapter, dev, custom) +2. Sets `tool_interface_version` to `"1.0"` on the AgentSpec +3. In production mode (`--prod`), removes all dev-category tools +4. Counts tools per category for the build manifest + +## Related Files + +- `internal/tools/tool.go` — Tool interface and category constants +- `internal/tools/registry.go` — Thread-safe tool registry +- `internal/tools/builtins/` — Built-in tool implementations +- `internal/tools/adapters/` — Adapter tool implementations +- `internal/tools/devtools/` — Development tools +- `internal/tools/discovery.go` — Tool discovery from project files +- `internal/build/tool_filter_stage.go` — Build-time tool filtering diff --git a/forge-cli/build/agentspec_stage.go b/forge-cli/build/agentspec_stage.go new file mode 100644 index 0000000..d724f34 --- /dev/null +++ b/forge-cli/build/agentspec_stage.go @@ -0,0 +1,43 @@ +package build + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/initializ/forge/forge-core/compiler" + "github.com/initializ/forge/forge-core/pipeline" +) + +// AgentSpecStage generates agent.json from ForgeConfig. +type AgentSpecStage struct{} + +func (s *AgentSpecStage) Name() string { return "generate-agentspec" } + +func (s *AgentSpecStage) Execute(ctx context.Context, bc *pipeline.BuildContext) error { + spec := compiler.ConfigToAgentSpec(bc.Config) + + if bc.PluginConfig != nil { + compiler.MergePluginConfig(spec, bc.PluginConfig) + } + if bc.WrapperFile != "" { + spec.Runtime.Entrypoint = compiler.WrapperEntrypoint(bc.WrapperFile) + } + + bc.Spec = spec + + data, err := json.MarshalIndent(spec, "", " ") + if err != nil { + return fmt.Errorf("marshalling agent spec: %w", err) + } + + outPath := filepath.Join(bc.Opts.OutputDir, "agent.json") + if err := os.WriteFile(outPath, data, 0644); err != nil { + return fmt.Errorf("writing agent.json: %w", err) + } + + bc.AddFile("agent.json", outPath) + return nil +} diff --git a/forge-cli/build/agentspec_stage_test.go b/forge-cli/build/agentspec_stage_test.go new file mode 100644 index 0000000..8838e5f --- /dev/null +++ b/forge-cli/build/agentspec_stage_test.go @@ -0,0 +1,244 @@ +package build + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/initializ/forge/forge-core/agentspec" + "github.com/initializ/forge/forge-core/compiler" + "github.com/initializ/forge/forge-core/pipeline" + "github.com/initializ/forge/forge-core/plugins" + "github.com/initializ/forge/forge-core/types" +) + +func testConfig() *types.ForgeConfig { + return &types.ForgeConfig{ + AgentID: "test-agent", + Version: "0.1.0", + Framework: "langchain", + Entrypoint: "python agent.py", + Model: types.ModelRef{ + Provider: "openai", + Name: "gpt-4", + }, + Tools: []types.ToolRef{ + {Name: "web-search", Type: "builtin"}, + }, + } +} + +func TestConfigToAgentSpec(t *testing.T) { + cfg := testConfig() + spec := compiler.ConfigToAgentSpec(cfg) + + if spec.ForgeVersion != "1.0" { + t.Errorf("ForgeVersion = %q, want %q", spec.ForgeVersion, "1.0") + } + if spec.AgentID != "test-agent" { + t.Errorf("AgentID = %q, want %q", spec.AgentID, "test-agent") + } + if spec.Version != "0.1.0" { + t.Errorf("Version = %q, want %q", spec.Version, "0.1.0") + } + if spec.Name != "test-agent" { + t.Errorf("Name = %q, want %q", spec.Name, "test-agent") + } + if spec.Runtime == nil { + t.Fatal("Runtime is nil") + } + if spec.Runtime.Port != 8080 { + t.Errorf("Port = %d, want 8080", spec.Runtime.Port) + } + if len(spec.Runtime.Entrypoint) != 2 || spec.Runtime.Entrypoint[0] != "python" { + t.Errorf("Entrypoint = %v, want [python agent.py]", spec.Runtime.Entrypoint) + } + if len(spec.Tools) != 1 || spec.Tools[0].Name != "web-search" { + t.Errorf("Tools = %v, want [{web-search}]", spec.Tools) + } + if spec.Model == nil || spec.Model.Provider != "openai" { + t.Error("Model not set correctly") + } +} + +func TestInferBaseImage(t *testing.T) { + tests := []struct { + ep []string + want string + }{ + {[]string{"python", "agent.py"}, "python:3.12-slim"}, + {[]string{"python3", "agent.py"}, "python:3.12-slim"}, + {[]string{"bun", "run", "agent.ts"}, "oven/bun:latest"}, + {[]string{"go", "run", "."}, "golang:1.23-alpine"}, + {[]string{"node", "index.js"}, "node:20-slim"}, + {[]string{"java", "-jar", "app.jar"}, "ubuntu:latest"}, + {[]string{}, "ubuntu:latest"}, + } + for _, tt := range tests { + got := compiler.InferBaseImage(tt.ep) + if got != tt.want { + t.Errorf("InferBaseImage(%v) = %q, want %q", tt.ep, got, tt.want) + } + } +} + +func TestAgentSpecStage_Execute(t *testing.T) { + outDir := t.TempDir() + bc := pipeline.NewBuildContext(pipeline.PipelineOptions{OutputDir: outDir}) + bc.Config = testConfig() + + stage := &AgentSpecStage{} + if err := stage.Execute(context.Background(), bc); err != nil { + t.Fatalf("Execute() error: %v", err) + } + + if bc.Spec == nil { + t.Fatal("Spec not set on BuildContext") + } + + data, err := os.ReadFile(filepath.Join(outDir, "agent.json")) + if err != nil { + t.Fatalf("reading agent.json: %v", err) + } + + var spec agentspec.AgentSpec + if err := json.Unmarshal(data, &spec); err != nil { + t.Fatalf("unmarshalling agent.json: %v", err) + } + if spec.AgentID != "test-agent" { + t.Errorf("agent.json AgentID = %q, want %q", spec.AgentID, "test-agent") + } + + if _, ok := bc.GeneratedFiles["agent.json"]; !ok { + t.Error("agent.json not recorded in GeneratedFiles") + } +} + +func TestMergePluginConfig_FillsGaps(t *testing.T) { + spec := &agentspec.AgentSpec{ + AgentID: "test-agent", + Name: "test-agent", // same as AgentID, should be overwritten + Tools: []agentspec.ToolSpec{ + {Name: "web-search"}, + }, + } + + pc := &plugins.AgentConfig{ + Name: "Research Agent", + Description: "An agent that researches topics", + Tools: []plugins.ToolDefinition{ + {Name: "web-search", Description: "Search the web"}, // enrich existing + {Name: "calculator", Description: "Do math"}, // new tool + }, + Model: &plugins.PluginModelConfig{Provider: "openai", Name: "gpt-4"}, + } + + compiler.MergePluginConfig(spec, pc) + + if spec.Name != "Research Agent" { + t.Errorf("Name = %q, want 'Research Agent'", spec.Name) + } + if spec.Description != "An agent that researches topics" { + t.Errorf("Description = %q", spec.Description) + } + if len(spec.Tools) != 2 { + t.Fatalf("expected 2 tools, got %d", len(spec.Tools)) + } + if spec.Tools[0].Description != "Search the web" { + t.Errorf("Tool[0].Description = %q, want 'Search the web'", spec.Tools[0].Description) + } + if spec.Tools[1].Name != "calculator" { + t.Errorf("Tool[1].Name = %q, want calculator", spec.Tools[1].Name) + } + if spec.Model == nil || spec.Model.Provider != "openai" { + t.Error("Model not merged correctly") + } +} + +func TestMergePluginConfig_ForgeYAMLPrecedence(t *testing.T) { + spec := &agentspec.AgentSpec{ + AgentID: "my-agent", + Name: "My Custom Name", // different from AgentID, should NOT be overwritten + Description: "Already set", + Model: &agentspec.ModelConfig{ + Provider: "anthropic", + Name: "claude-sonnet-4-20250514", + }, + } + + pc := &plugins.AgentConfig{ + Name: "Plugin Name", + Description: "Plugin description", + Model: &plugins.PluginModelConfig{Provider: "openai", Name: "gpt-4"}, + } + + compiler.MergePluginConfig(spec, pc) + + if spec.Name != "My Custom Name" { + t.Errorf("Name should not be overwritten, got %q", spec.Name) + } + if spec.Description != "Already set" { + t.Errorf("Description should not be overwritten, got %q", spec.Description) + } + if spec.Model.Provider != "anthropic" { + t.Errorf("Model should not be overwritten, got %q", spec.Model.Provider) + } +} + +func TestWrapperEntrypoint(t *testing.T) { + tests := []struct { + file string + want []string + }{ + {"a2a_wrapper.py", []string{"python", "a2a_wrapper.py"}}, + {"wrapper.ts", []string{"bun", "run", "wrapper.ts"}}, + {"wrapper.go", []string{"go", "run", "wrapper.go"}}, + {"wrapper.unknown", []string{"python", "wrapper.unknown"}}, + } + for _, tt := range tests { + got := compiler.WrapperEntrypoint(tt.file) + if len(got) != len(tt.want) { + t.Errorf("WrapperEntrypoint(%q) = %v, want %v", tt.file, got, tt.want) + continue + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("WrapperEntrypoint(%q)[%d] = %q, want %q", tt.file, i, got[i], tt.want[i]) + } + } + } +} + +func TestAgentSpecStage_WithPluginConfig(t *testing.T) { + outDir := t.TempDir() + bc := pipeline.NewBuildContext(pipeline.PipelineOptions{OutputDir: outDir}) + bc.Config = &types.ForgeConfig{ + AgentID: "test-agent", + Version: "0.1.0", + Entrypoint: "python agent.py", + } + bc.PluginConfig = &plugins.AgentConfig{ + Description: "Plugin-provided description", + Tools: []plugins.ToolDefinition{ + {Name: "plugin-tool", Description: "From plugin"}, + }, + } + bc.WrapperFile = "a2a_wrapper.py" + + stage := &AgentSpecStage{} + if err := stage.Execute(context.Background(), bc); err != nil { + t.Fatalf("Execute error: %v", err) + } + + if bc.Spec.Description != "Plugin-provided description" { + t.Errorf("Description = %q", bc.Spec.Description) + } + if len(bc.Spec.Tools) != 1 || bc.Spec.Tools[0].Name != "plugin-tool" { + t.Errorf("Tools = %v", bc.Spec.Tools) + } + if bc.Spec.Runtime.Entrypoint[0] != "python" || bc.Spec.Runtime.Entrypoint[1] != "a2a_wrapper.py" { + t.Errorf("Entrypoint = %v, expected wrapper entrypoint", bc.Spec.Runtime.Entrypoint) + } +} diff --git a/forge-cli/build/dockerfile_stage.go b/forge-cli/build/dockerfile_stage.go new file mode 100644 index 0000000..d134d59 --- /dev/null +++ b/forge-cli/build/dockerfile_stage.go @@ -0,0 +1,46 @@ +package build + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "text/template" + + "github.com/initializ/forge/forge-cli/templates" + "github.com/initializ/forge/forge-core/compiler" + "github.com/initializ/forge/forge-core/pipeline" +) + +// DockerfileStage generates a Dockerfile from the embedded template. +type DockerfileStage struct{} + +func (s *DockerfileStage) Name() string { return "generate-dockerfile" } + +func (s *DockerfileStage) Execute(ctx context.Context, bc *pipeline.BuildContext) error { + tmplData, err := templates.FS.ReadFile("Dockerfile.tmpl") + if err != nil { + return fmt.Errorf("reading Dockerfile template: %w", err) + } + + tmpl, err := template.New("Dockerfile").Parse(string(tmplData)) + if err != nil { + return fmt.Errorf("parsing Dockerfile template: %w", err) + } + + data := compiler.BuildTemplateDataFromContext(bc.Spec, bc) + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return fmt.Errorf("rendering Dockerfile: %w", err) + } + + outPath := filepath.Join(bc.Opts.OutputDir, "Dockerfile") + if err := os.WriteFile(outPath, buf.Bytes(), 0644); err != nil { + return fmt.Errorf("writing Dockerfile: %w", err) + } + + bc.AddFile("Dockerfile", outPath) + return nil +} diff --git a/forge-cli/build/dockerfile_stage_test.go b/forge-cli/build/dockerfile_stage_test.go new file mode 100644 index 0000000..c7a74d0 --- /dev/null +++ b/forge-cli/build/dockerfile_stage_test.go @@ -0,0 +1,51 @@ +package build + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/initializ/forge/forge-core/agentspec" + "github.com/initializ/forge/forge-core/pipeline" +) + +func TestDockerfileStage_Execute(t *testing.T) { + outDir := t.TempDir() + bc := pipeline.NewBuildContext(pipeline.PipelineOptions{OutputDir: outDir}) + bc.Spec = &agentspec.AgentSpec{ + AgentID: "test-agent", + Version: "0.1.0", + Runtime: &agentspec.RuntimeConfig{ + Image: "python:3.12-slim", + Entrypoint: []string{"python", "agent.py"}, + Port: 8080, + }, + } + + stage := &DockerfileStage{} + if err := stage.Execute(context.Background(), bc); err != nil { + t.Fatalf("Execute() error: %v", err) + } + + data, err := os.ReadFile(filepath.Join(outDir, "Dockerfile")) + if err != nil { + t.Fatalf("reading Dockerfile: %v", err) + } + + content := string(data) + if !strings.Contains(content, "FROM python:3.12-slim") { + t.Error("Dockerfile missing FROM line") + } + if !strings.Contains(content, `ENTRYPOINT ["python","agent.py"]`) { + t.Errorf("Dockerfile missing/wrong ENTRYPOINT, got:\n%s", content) + } + if !strings.Contains(content, "EXPOSE 8080") { + t.Error("Dockerfile missing EXPOSE") + } + + if _, ok := bc.GeneratedFiles["Dockerfile"]; !ok { + t.Error("Dockerfile not recorded in GeneratedFiles") + } +} diff --git a/forge-cli/build/egress_stage.go b/forge-cli/build/egress_stage.go new file mode 100644 index 0000000..25d38e1 --- /dev/null +++ b/forge-cli/build/egress_stage.go @@ -0,0 +1,65 @@ +package build + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/initializ/forge/forge-core/pipeline" + "github.com/initializ/forge/forge-core/security" +) + +// EgressStage resolves egress configuration and generates allowlist artifacts. +type EgressStage struct{} + +func (s *EgressStage) Name() string { return "resolve-egress" } + +func (s *EgressStage) Execute(ctx context.Context, bc *pipeline.BuildContext) error { + cfg := bc.Config.Egress + + // No-op if no egress config + if cfg.Profile == "" && cfg.Mode == "" { + return nil + } + + // Collect tool names for domain inference + var toolNames []string + if bc.Spec != nil { + for _, t := range bc.Spec.Tools { + toolNames = append(toolNames, t.Name) + } + } + + resolved, err := security.Resolve(cfg.Profile, cfg.Mode, cfg.AllowedDomains, toolNames, cfg.Capabilities) + if err != nil { + return fmt.Errorf("resolving egress: %w", err) + } + + bc.EgressResolved = resolved + + // Set egress fields on spec + if bc.Spec != nil { + bc.Spec.EgressProfile = string(resolved.Profile) + bc.Spec.EgressMode = string(resolved.Mode) + } + + // Write egress_allowlist.json + data, err := security.GenerateAllowlistJSON(resolved) + if err != nil { + return fmt.Errorf("generating egress allowlist: %w", err) + } + + compiledDir := filepath.Join(bc.Opts.OutputDir, "compiled") + if err := os.MkdirAll(compiledDir, 0755); err != nil { + return fmt.Errorf("creating compiled directory: %w", err) + } + + outPath := filepath.Join(compiledDir, "egress_allowlist.json") + if err := os.WriteFile(outPath, data, 0644); err != nil { + return fmt.Errorf("writing egress_allowlist.json: %w", err) + } + + bc.AddFile("compiled/egress_allowlist.json", outPath) + return nil +} diff --git a/forge-cli/build/egress_stage_test.go b/forge-cli/build/egress_stage_test.go new file mode 100644 index 0000000..1379917 --- /dev/null +++ b/forge-cli/build/egress_stage_test.go @@ -0,0 +1,139 @@ +package build + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/initializ/forge/forge-core/agentspec" + "github.com/initializ/forge/forge-core/pipeline" + "github.com/initializ/forge/forge-core/security" + "github.com/initializ/forge/forge-core/types" +) + +func TestEgressStage_NoConfig(t *testing.T) { + tmpDir := t.TempDir() + bc := pipeline.NewBuildContext(pipeline.PipelineOptions{OutputDir: tmpDir, WorkDir: tmpDir}) + bc.Config = &types.ForgeConfig{AgentID: "test", Version: "1.0.0", Entrypoint: "python main.py"} + bc.Spec = &agentspec.AgentSpec{AgentID: "test"} + + stage := &EgressStage{} + if err := stage.Execute(context.Background(), bc); err != nil { + t.Fatalf("Execute: %v", err) + } + // Should be no-op + if bc.EgressResolved != nil { + t.Error("EgressResolved should be nil when no egress config") + } +} + +func TestEgressStage_DenyAll(t *testing.T) { + tmpDir := t.TempDir() + bc := pipeline.NewBuildContext(pipeline.PipelineOptions{OutputDir: tmpDir, WorkDir: tmpDir}) + bc.Config = &types.ForgeConfig{ + AgentID: "test", + Version: "1.0.0", + Entrypoint: "python main.py", + Egress: types.EgressRef{ + Profile: "strict", + Mode: "deny-all", + }, + } + bc.Spec = &agentspec.AgentSpec{AgentID: "test"} + + stage := &EgressStage{} + if err := stage.Execute(context.Background(), bc); err != nil { + t.Fatalf("Execute: %v", err) + } + if bc.EgressResolved == nil { + t.Fatal("EgressResolved should not be nil") + } + if bc.Spec.EgressProfile != "strict" { + t.Errorf("EgressProfile = %q, want %q", bc.Spec.EgressProfile, "strict") + } + + // Check allowlist file exists + allowlistPath := filepath.Join(tmpDir, "compiled", "egress_allowlist.json") + if _, err := os.Stat(allowlistPath); os.IsNotExist(err) { + t.Error("egress_allowlist.json not created") + } +} + +func TestEgressStage_Allowlist(t *testing.T) { + tmpDir := t.TempDir() + bc := pipeline.NewBuildContext(pipeline.PipelineOptions{OutputDir: tmpDir, WorkDir: tmpDir}) + bc.Config = &types.ForgeConfig{ + AgentID: "test", + Version: "1.0.0", + Entrypoint: "python main.py", + Egress: types.EgressRef{ + Profile: "standard", + Mode: "allowlist", + AllowedDomains: []string{"api.example.com"}, + }, + } + bc.Spec = &agentspec.AgentSpec{ + AgentID: "test", + Tools: []agentspec.ToolSpec{{Name: "web_search"}}, + } + + stage := &EgressStage{} + if err := stage.Execute(context.Background(), bc); err != nil { + t.Fatalf("Execute: %v", err) + } + if bc.Spec.EgressMode != "allowlist" { + t.Errorf("EgressMode = %q, want %q", bc.Spec.EgressMode, "allowlist") + } +} + +func TestEgressStage_AllowlistWithCapabilities(t *testing.T) { + tmpDir := t.TempDir() + bc := pipeline.NewBuildContext(pipeline.PipelineOptions{OutputDir: tmpDir, WorkDir: tmpDir}) + bc.Config = &types.ForgeConfig{ + AgentID: "test", + Version: "1.0.0", + Entrypoint: "python main.py", + Egress: types.EgressRef{ + Profile: "standard", + Mode: "allowlist", + Capabilities: []string{"slack"}, + }, + } + bc.Spec = &agentspec.AgentSpec{ + AgentID: "test", + } + + stage := &EgressStage{} + if err := stage.Execute(context.Background(), bc); err != nil { + t.Fatalf("Execute: %v", err) + } + if bc.EgressResolved == nil { + t.Fatal("EgressResolved should not be nil") + } + + // Type-assert to *security.EgressConfig (stored as any to avoid import cycle) + resolved, ok := bc.EgressResolved.(*security.EgressConfig) + if !ok { + t.Fatalf("EgressResolved has unexpected type %T", bc.EgressResolved) + } + + // Check that slack domains are in the resolved AllDomains + wantDomains := map[string]bool{ + "slack.com": true, + "hooks.slack.com": true, + "api.slack.com": true, + } + for d := range wantDomains { + found := false + for _, got := range resolved.AllDomains { + if got == d { + found = true + break + } + } + if !found { + t.Errorf("expected %q in AllDomains, got %v", d, resolved.AllDomains) + } + } +} diff --git a/forge-cli/build/framework_adapter_stage.go b/forge-cli/build/framework_adapter_stage.go new file mode 100644 index 0000000..4c81560 --- /dev/null +++ b/forge-cli/build/framework_adapter_stage.go @@ -0,0 +1,74 @@ +package build + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/initializ/forge/forge-core/pipeline" + "github.com/initializ/forge/forge-core/plugins" +) + +// FrameworkAdapterStage detects the agent framework, extracts configuration, +// and generates an A2A wrapper if needed. +type FrameworkAdapterStage struct { + Registry *plugins.FrameworkRegistry +} + +func (s *FrameworkAdapterStage) Name() string { return "framework-adapter" } + +func (s *FrameworkAdapterStage) Execute(ctx context.Context, bc *pipeline.BuildContext) error { + if s.Registry == nil { + return nil + } + + // Resolve plugin: explicit framework config or auto-detect + var plugin plugins.FrameworkPlugin + if bc.Config.Framework != "" { + plugin = s.Registry.Get(bc.Config.Framework) + } + if plugin == nil { + var err error + plugin, err = s.Registry.Detect(bc.Opts.WorkDir) + if err != nil { + return fmt.Errorf("detecting framework: %w", err) + } + } + + if plugin == nil { + return nil // no framework detected, skip silently + } + + // Extract agent config from source code + agentConfig, err := plugin.ExtractAgentConfig(bc.Opts.WorkDir) + if err != nil { + return fmt.Errorf("extracting agent config (%s): %w", plugin.Name(), err) + } + bc.PluginConfig = agentConfig + + // Generate wrapper + wrapperData, err := plugin.GenerateWrapper(agentConfig) + if err != nil { + return fmt.Errorf("generating wrapper (%s): %w", plugin.Name(), err) + } + + if wrapperData != nil { + wrapperName := "a2a_wrapper.py" + wrapperPath := filepath.Join(bc.Opts.OutputDir, wrapperName) + if err := os.WriteFile(wrapperPath, wrapperData, 0644); err != nil { + return fmt.Errorf("writing wrapper: %w", err) + } + bc.WrapperFile = wrapperName + bc.AddFile(wrapperName, wrapperPath) + } + + // Log runtime dependencies as warnings + if bc.Verbose { + for _, dep := range plugin.RuntimeDependencies() { + bc.AddWarning(fmt.Sprintf("framework runtime dependency: %s", dep)) + } + } + + return nil +} diff --git a/forge-cli/build/framework_adapter_stage_test.go b/forge-cli/build/framework_adapter_stage_test.go new file mode 100644 index 0000000..ce65407 --- /dev/null +++ b/forge-cli/build/framework_adapter_stage_test.go @@ -0,0 +1,177 @@ +package build + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/initializ/forge/forge-cli/plugins/crewai" + "github.com/initializ/forge/forge-cli/plugins/custom" + "github.com/initializ/forge/forge-cli/plugins/langchain" + "github.com/initializ/forge/forge-core/pipeline" + "github.com/initializ/forge/forge-core/plugins" + "github.com/initializ/forge/forge-core/types" +) + +func newTestRegistry() *plugins.FrameworkRegistry { + reg := plugins.NewFrameworkRegistry() + reg.Register(&crewai.Plugin{}) + reg.Register(&langchain.Plugin{}) + reg.Register(&custom.Plugin{}) + return reg +} + +func TestFrameworkAdapterStage_ExplicitFramework(t *testing.T) { + workDir := t.TempDir() + outDir := t.TempDir() + + // Create a crewai project + _ = os.WriteFile(filepath.Join(workDir, "agent.py"), []byte(` +from crewai import Agent +agent = Agent(role="Tester", goal="Test things") +`), 0644) + + bc := pipeline.NewBuildContext(pipeline.PipelineOptions{ + WorkDir: workDir, + OutputDir: outDir, + }) + bc.Config = &types.ForgeConfig{ + AgentID: "test-agent", + Version: "0.1.0", + Framework: "crewai", + Entrypoint: "python agent.py", + } + + stage := &FrameworkAdapterStage{Registry: newTestRegistry()} + if err := stage.Execute(context.Background(), bc); err != nil { + t.Fatalf("Execute error: %v", err) + } + + if bc.PluginConfig == nil { + t.Fatal("expected PluginConfig to be set") + } + + // CrewAI should generate a wrapper + if bc.WrapperFile == "" { + t.Error("expected WrapperFile to be set for crewai") + } + + // Wrapper file should exist on disk + if _, err := os.Stat(filepath.Join(outDir, bc.WrapperFile)); os.IsNotExist(err) { + t.Error("wrapper file not found on disk") + } +} + +func TestFrameworkAdapterStage_AutoDetect(t *testing.T) { + workDir := t.TempDir() + outDir := t.TempDir() + + // Create langchain markers + _ = os.WriteFile(filepath.Join(workDir, "requirements.txt"), []byte("langchain\n"), 0644) + _ = os.WriteFile(filepath.Join(workDir, "agent.py"), []byte(` +from langchain.tools import tool +@tool +def search(query: str) -> str: + """Search the web""" + return "result" +`), 0644) + + bc := pipeline.NewBuildContext(pipeline.PipelineOptions{ + WorkDir: workDir, + OutputDir: outDir, + }) + bc.Config = &types.ForgeConfig{ + AgentID: "test-agent", + Version: "0.1.0", + Framework: "", // auto-detect + Entrypoint: "python agent.py", + } + + stage := &FrameworkAdapterStage{Registry: newTestRegistry()} + if err := stage.Execute(context.Background(), bc); err != nil { + t.Fatalf("Execute error: %v", err) + } + + if bc.PluginConfig == nil { + t.Fatal("expected PluginConfig to be set via auto-detect") + } + if bc.WrapperFile == "" { + t.Error("expected WrapperFile from langchain auto-detect") + } +} + +func TestFrameworkAdapterStage_CustomNoWrapper(t *testing.T) { + workDir := t.TempDir() + outDir := t.TempDir() + + bc := pipeline.NewBuildContext(pipeline.PipelineOptions{ + WorkDir: workDir, + OutputDir: outDir, + }) + bc.Config = &types.ForgeConfig{ + AgentID: "test-agent", + Version: "0.1.0", + Framework: "custom", + Entrypoint: "python agent.py", + } + + stage := &FrameworkAdapterStage{Registry: newTestRegistry()} + if err := stage.Execute(context.Background(), bc); err != nil { + t.Fatalf("Execute error: %v", err) + } + + if bc.PluginConfig == nil { + t.Fatal("expected PluginConfig to be set") + } + + // Custom should NOT generate a wrapper + if bc.WrapperFile != "" { + t.Errorf("expected empty WrapperFile for custom, got %q", bc.WrapperFile) + } +} + +func TestFrameworkAdapterStage_NilRegistry(t *testing.T) { + bc := pipeline.NewBuildContext(pipeline.PipelineOptions{}) + bc.Config = &types.ForgeConfig{ + AgentID: "test-agent", + Version: "0.1.0", + Entrypoint: "python agent.py", + } + + stage := &FrameworkAdapterStage{Registry: nil} + if err := stage.Execute(context.Background(), bc); err != nil { + t.Fatalf("Execute error: %v", err) + } + + if bc.PluginConfig != nil { + t.Error("expected nil PluginConfig with nil registry") + } +} + +func TestFrameworkAdapterStage_VerboseDeps(t *testing.T) { + workDir := t.TempDir() + outDir := t.TempDir() + + bc := pipeline.NewBuildContext(pipeline.PipelineOptions{ + WorkDir: workDir, + OutputDir: outDir, + }) + bc.Config = &types.ForgeConfig{ + AgentID: "test-agent", + Version: "0.1.0", + Framework: "crewai", + Entrypoint: "python agent.py", + } + bc.Verbose = true + + stage := &FrameworkAdapterStage{Registry: newTestRegistry()} + if err := stage.Execute(context.Background(), bc); err != nil { + t.Fatalf("Execute error: %v", err) + } + + // Should have warnings for runtime deps + if len(bc.Warnings) == 0 { + t.Error("expected warnings for runtime dependencies in verbose mode") + } +} diff --git a/forge-cli/build/integration_test.go b/forge-cli/build/integration_test.go new file mode 100644 index 0000000..a0cf337 --- /dev/null +++ b/forge-cli/build/integration_test.go @@ -0,0 +1,206 @@ +//go:build integration + +package build_test + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/initializ/forge/forge-cli/build" + "github.com/initializ/forge/forge-core/agentspec" + "github.com/initializ/forge/forge-core/pipeline" + "github.com/initializ/forge/forge-core/types" +) + +// findProjectRoot walks up from the current directory to find go.mod. +func findProjectRoot(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd: %v", err) + } + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatal("could not find project root (go.mod)") + } + dir = parent + } +} + +func TestIntegration_BuildWithSkillsAndEgress(t *testing.T) { + root := findProjectRoot(t) + outDir := t.TempDir() + workDir := root + + // Create a skills.md in a temp work dir + skillsDir := t.TempDir() + skillsContent := []byte("## Tool: web_search\nSearch the web for information.\n\n**Input:** query: string\n**Output:** results: []string\n") + if err := os.WriteFile(filepath.Join(skillsDir, "skills.md"), skillsContent, 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + cfg := &types.ForgeConfig{ + AgentID: "test-agent", + Version: "1.0.0", + Framework: "custom", + Entrypoint: "python main.py", + Skills: types.SkillsRef{Path: filepath.Join(skillsDir, "skills.md")}, + Egress: types.EgressRef{ + Profile: "standard", + Mode: "allowlist", + AllowedDomains: []string{"api.example.com"}, + }, + } + + bc := pipeline.NewBuildContext(pipeline.PipelineOptions{ + WorkDir: workDir, + OutputDir: outDir, + }) + bc.Config = cfg + bc.Spec = &agentspec.AgentSpec{ + ForgeVersion: "1.0.0", + AgentID: "test-agent", + Version: "1.0.0", + Name: "test-agent", + Tools: []agentspec.ToolSpec{ + {Name: "web_search"}, + }, + } + + // Run skills + egress + manifest stages + p := pipeline.New( + &build.SkillsStage{}, + &build.EgressStage{}, + &build.ManifestStage{}, + ) + + if err := p.Run(context.Background(), bc); err != nil { + t.Fatalf("pipeline.Run: %v", err) + } + + // Verify skills artifacts + skillsPath := filepath.Join(outDir, "compiled", "skills", "skills.json") + if _, err := os.Stat(skillsPath); os.IsNotExist(err) { + t.Error("expected skills.json not found") + } else { + data, err := os.ReadFile(skillsPath) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + var skills map[string]any + if err := json.Unmarshal(data, &skills); err != nil { + t.Fatalf("unmarshal skills.json: %v", err) + } + if skills["count"].(float64) != 1 { + t.Errorf("skills count = %v, want 1", skills["count"]) + } + } + + // Verify egress allowlist + egressPath := filepath.Join(outDir, "compiled", "egress_allowlist.json") + if _, err := os.Stat(egressPath); os.IsNotExist(err) { + t.Error("expected egress_allowlist.json not found") + } else { + data, err := os.ReadFile(egressPath) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + var egress map[string]any + if err := json.Unmarshal(data, &egress); err != nil { + t.Fatalf("unmarshal egress_allowlist.json: %v", err) + } + if egress["profile"] != "standard" { + t.Errorf("egress profile = %v, want standard", egress["profile"]) + } + if egress["mode"] != "allowlist" { + t.Errorf("egress mode = %v, want allowlist", egress["mode"]) + } + } + + // Verify spec was updated + if bc.Spec.SkillsSpecVersion != "agentskills-v1" { + t.Errorf("SkillsSpecVersion = %q, want agentskills-v1", bc.Spec.SkillsSpecVersion) + } + if bc.Spec.EgressProfile != "standard" { + t.Errorf("EgressProfile = %q, want standard", bc.Spec.EgressProfile) + } + + // Verify build manifest + manifestPath := filepath.Join(outDir, "build-manifest.json") + if _, err := os.Stat(manifestPath); os.IsNotExist(err) { + t.Error("expected build-manifest.json not found") + } +} + +func TestIntegration_BuildExportRoundTrip(t *testing.T) { + outDir := t.TempDir() + + cfg := &types.ForgeConfig{ + AgentID: "roundtrip-agent", + Version: "2.0.0", + Framework: "custom", + Entrypoint: "python main.py", + Egress: types.EgressRef{ + Profile: "strict", + Mode: "deny-all", + }, + } + + bc := pipeline.NewBuildContext(pipeline.PipelineOptions{ + WorkDir: t.TempDir(), + OutputDir: outDir, + }) + bc.Config = cfg + bc.Spec = &agentspec.AgentSpec{ + ForgeVersion: "1.0.0", + AgentID: "roundtrip-agent", + Version: "2.0.0", + Name: "roundtrip-agent", + Tools: []agentspec.ToolSpec{ + {Name: "web_search"}, + {Name: "github_api"}, + }, + } + + p := pipeline.New( + &build.EgressStage{}, + &build.ManifestStage{}, + ) + + if err := p.Run(context.Background(), bc); err != nil { + t.Fatalf("pipeline.Run: %v", err) + } + + // Read and validate build manifest + manifestPath := filepath.Join(outDir, "build-manifest.json") + data, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + + var manifest map[string]any + if err := json.Unmarshal(data, &manifest); err != nil { + t.Fatalf("unmarshal manifest: %v", err) + } + + // Verify all expected fields + if manifest["agent_id"] != "roundtrip-agent" { + t.Errorf("agent_id = %v, want roundtrip-agent", manifest["agent_id"]) + } + if manifest["version"] != "2.0.0" { + t.Errorf("version = %v, want 2.0.0", manifest["version"]) + } + if manifest["egress_profile"] != "strict" { + t.Errorf("egress_profile = %v, want strict", manifest["egress_profile"]) + } + if manifest["egress_mode"] != "deny-all" { + t.Errorf("egress_mode = %v, want deny-all", manifest["egress_mode"]) + } +} diff --git a/forge-cli/build/k8s_stage.go b/forge-cli/build/k8s_stage.go new file mode 100644 index 0000000..34254e9 --- /dev/null +++ b/forge-cli/build/k8s_stage.go @@ -0,0 +1,89 @@ +package build + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "text/template" + + "github.com/initializ/forge/forge-cli/templates" + "github.com/initializ/forge/forge-core/compiler" + "github.com/initializ/forge/forge-core/pipeline" + "github.com/initializ/forge/forge-core/security" +) + +// K8sStage generates Kubernetes deployment and service manifests. +type K8sStage struct{} + +func (s *K8sStage) Name() string { return "generate-k8s-manifests" } + +func (s *K8sStage) Execute(ctx context.Context, bc *pipeline.BuildContext) error { + k8sDir := filepath.Join(bc.Opts.OutputDir, "k8s") + if err := os.MkdirAll(k8sDir, 0755); err != nil { + return fmt.Errorf("creating k8s directory: %w", err) + } + + data := compiler.BuildTemplateDataFromContext(bc.Spec, bc) + + manifests := []struct { + tmplFile string + outFile string + optional bool + }{ + {"deployment.yaml.tmpl", "deployment.yaml", false}, + {"service.yaml.tmpl", "service.yaml", false}, + {"network-policy.yaml.tmpl", "network-policy.yaml", true}, + {"secrets.yaml.tmpl", "secrets.yaml", true}, + } + + for _, m := range manifests { + // Special handling for network-policy: use egress resolver if available + if m.outFile == "network-policy.yaml" && bc.EgressResolved != nil { + egressCfg, ok := bc.EgressResolved.(*security.EgressConfig) + if ok { + policyData, err := security.GenerateK8sNetworkPolicy(bc.Spec.AgentID, egressCfg) + if err != nil { + return fmt.Errorf("generating network policy from egress config: %w", err) + } + outPath := filepath.Join(k8sDir, m.outFile) + if err := os.WriteFile(outPath, policyData, 0644); err != nil { + return fmt.Errorf("writing %s: %w", m.outFile, err) + } + bc.AddFile(filepath.Join("k8s", m.outFile), outPath) + continue + } + } + + tmplData, err := templates.FS.ReadFile(m.tmplFile) + if err != nil { + if m.optional { + continue + } + return fmt.Errorf("reading template %s: %w", m.tmplFile, err) + } + + tmpl, err := template.New(m.tmplFile).Parse(string(tmplData)) + if err != nil { + return fmt.Errorf("parsing template %s: %w", m.tmplFile, err) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + if m.optional { + continue + } + return fmt.Errorf("rendering %s: %w", m.tmplFile, err) + } + + outPath := filepath.Join(k8sDir, m.outFile) + if err := os.WriteFile(outPath, buf.Bytes(), 0644); err != nil { + return fmt.Errorf("writing %s: %w", m.outFile, err) + } + + bc.AddFile(filepath.Join("k8s", m.outFile), outPath) + } + + return nil +} diff --git a/forge-cli/build/k8s_stage_test.go b/forge-cli/build/k8s_stage_test.go new file mode 100644 index 0000000..beffb1a --- /dev/null +++ b/forge-cli/build/k8s_stage_test.go @@ -0,0 +1,67 @@ +package build + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/initializ/forge/forge-core/agentspec" + "github.com/initializ/forge/forge-core/pipeline" +) + +func TestK8sStage_Execute(t *testing.T) { + outDir := t.TempDir() + bc := pipeline.NewBuildContext(pipeline.PipelineOptions{OutputDir: outDir}) + bc.Spec = &agentspec.AgentSpec{ + AgentID: "test-agent", + Version: "0.1.0", + Runtime: &agentspec.RuntimeConfig{ + Image: "python:3.12-slim", + Entrypoint: []string{"python", "agent.py"}, + Port: 8080, + }, + } + + stage := &K8sStage{} + if err := stage.Execute(context.Background(), bc); err != nil { + t.Fatalf("Execute() error: %v", err) + } + + // Check deployment.yaml + depData, err := os.ReadFile(filepath.Join(outDir, "k8s", "deployment.yaml")) + if err != nil { + t.Fatalf("reading deployment.yaml: %v", err) + } + dep := string(depData) + if !strings.Contains(dep, "name: test-agent") { + t.Error("deployment.yaml missing agent name") + } + if !strings.Contains(dep, "image: python:3.12-slim") { + t.Error("deployment.yaml missing image reference") + } + if !strings.Contains(dep, "containerPort: 8080") { + t.Error("deployment.yaml missing container port") + } + + // Check service.yaml + svcData, err := os.ReadFile(filepath.Join(outDir, "k8s", "service.yaml")) + if err != nil { + t.Fatalf("reading service.yaml: %v", err) + } + svc := string(svcData) + if !strings.Contains(svc, "name: test-agent") { + t.Error("service.yaml missing agent name") + } + if !strings.Contains(svc, "targetPort: 8080") { + t.Error("service.yaml missing target port") + } + + if _, ok := bc.GeneratedFiles["k8s/deployment.yaml"]; !ok { + t.Error("k8s/deployment.yaml not recorded in GeneratedFiles") + } + if _, ok := bc.GeneratedFiles["k8s/service.yaml"]; !ok { + t.Error("k8s/service.yaml not recorded in GeneratedFiles") + } +} diff --git a/forge-cli/build/manifest_stage.go b/forge-cli/build/manifest_stage.go new file mode 100644 index 0000000..e423ea3 --- /dev/null +++ b/forge-cli/build/manifest_stage.go @@ -0,0 +1,73 @@ +package build + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "time" + + "github.com/initializ/forge/forge-core/pipeline" +) + +// ManifestStage writes the build-manifest.json with build metadata. +type ManifestStage struct{} + +func (s *ManifestStage) Name() string { return "write-build-manifest" } + +func (s *ManifestStage) Execute(ctx context.Context, bc *pipeline.BuildContext) error { + files := make([]string, 0, len(bc.GeneratedFiles)) + for rel := range bc.GeneratedFiles { + files = append(files, rel) + } + sort.Strings(files) + + manifest := map[string]any{ + "agent_id": bc.Spec.AgentID, + "version": bc.Spec.Version, + "built_at": time.Now().UTC().Format(time.RFC3339), + "output_dir": bc.Opts.OutputDir, + "files": files, + } + + // Add container packaging metadata + if bc.Spec.ForgeVersion != "" { + manifest["forge_version"] = bc.Spec.ForgeVersion + } + if bc.Spec.ToolInterfaceVersion != "" { + manifest["tool_interface_version"] = bc.Spec.ToolInterfaceVersion + } + if bc.Spec.SkillsSpecVersion != "" { + manifest["skills_spec_version"] = bc.Spec.SkillsSpecVersion + } + if bc.SkillsCount > 0 { + manifest["skills_count"] = bc.SkillsCount + } + if bc.Spec.EgressProfile != "" { + manifest["egress_profile"] = bc.Spec.EgressProfile + } + if bc.Spec.EgressMode != "" { + manifest["egress_mode"] = bc.Spec.EgressMode + } + if bc.DevMode { + manifest["dev_build"] = true + } + if len(bc.ToolCategoryCounts) > 0 { + manifest["tool_categories"] = bc.ToolCategoryCounts + } + + data, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return fmt.Errorf("marshalling build manifest: %w", err) + } + + outPath := filepath.Join(bc.Opts.OutputDir, "build-manifest.json") + if err := os.WriteFile(outPath, data, 0644); err != nil { + return fmt.Errorf("writing build-manifest.json: %w", err) + } + + bc.AddFile("build-manifest.json", outPath) + return nil +} diff --git a/forge-cli/build/manifest_stage_test.go b/forge-cli/build/manifest_stage_test.go new file mode 100644 index 0000000..c419fdc --- /dev/null +++ b/forge-cli/build/manifest_stage_test.go @@ -0,0 +1,57 @@ +package build + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/initializ/forge/forge-core/agentspec" + "github.com/initializ/forge/forge-core/pipeline" +) + +func TestManifestStage_Execute(t *testing.T) { + outDir := t.TempDir() + bc := pipeline.NewBuildContext(pipeline.PipelineOptions{OutputDir: outDir}) + bc.Spec = &agentspec.AgentSpec{ + AgentID: "test-agent", + Version: "0.1.0", + } + bc.AddFile("agent.json", filepath.Join(outDir, "agent.json")) + bc.AddFile("Dockerfile", filepath.Join(outDir, "Dockerfile")) + + stage := &ManifestStage{} + if err := stage.Execute(context.Background(), bc); err != nil { + t.Fatalf("Execute() error: %v", err) + } + + data, err := os.ReadFile(filepath.Join(outDir, "build-manifest.json")) + if err != nil { + t.Fatalf("reading build-manifest.json: %v", err) + } + + var manifest map[string]any + if err := json.Unmarshal(data, &manifest); err != nil { + t.Fatalf("unmarshalling manifest: %v", err) + } + + if manifest["agent_id"] != "test-agent" { + t.Errorf("agent_id = %v, want test-agent", manifest["agent_id"]) + } + if manifest["version"] != "0.1.0" { + t.Errorf("version = %v, want 0.1.0", manifest["version"]) + } + if manifest["built_at"] == nil { + t.Error("built_at is nil") + } + + files, ok := manifest["files"].([]any) + if !ok { + t.Fatalf("files is not an array: %T", manifest["files"]) + } + // Should include agent.json, Dockerfile, and build-manifest.json itself + if len(files) < 2 { + t.Errorf("expected at least 2 files, got %d", len(files)) + } +} diff --git a/forge-cli/build/network_policy_test.go b/forge-cli/build/network_policy_test.go new file mode 100644 index 0000000..6ae3ac4 --- /dev/null +++ b/forge-cli/build/network_policy_test.go @@ -0,0 +1,89 @@ +package build + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/initializ/forge/forge-core/agentspec" + "github.com/initializ/forge/forge-core/pipeline" +) + +func TestK8sStage_NetworkPolicy(t *testing.T) { + outDir := t.TempDir() + bc := pipeline.NewBuildContext(pipeline.PipelineOptions{OutputDir: outDir}) + bc.Spec = &agentspec.AgentSpec{ + AgentID: "test-agent", + Version: "0.1.0", + Runtime: &agentspec.RuntimeConfig{ + Image: "python:3.12-slim", + Entrypoint: []string{"python", "agent.py"}, + Port: 8080, + }, + } + + stage := &K8sStage{} + if err := stage.Execute(context.Background(), bc); err != nil { + t.Fatalf("Execute() error: %v", err) + } + + // Check network-policy.yaml was generated + npPath := filepath.Join(outDir, "k8s", "network-policy.yaml") + data, err := os.ReadFile(npPath) + if err != nil { + t.Fatalf("reading network-policy.yaml: %v", err) + } + + content := string(data) + if !strings.Contains(content, "kind: NetworkPolicy") { + t.Error("network-policy.yaml missing NetworkPolicy kind") + } + if !strings.Contains(content, "name: test-agent-network") { + t.Error("network-policy.yaml missing agent name") + } + if !strings.Contains(content, "app: test-agent") { + t.Error("network-policy.yaml missing app label") + } + + // Default should be deny-all (no tools registered) + if !strings.Contains(content, "egress: []") { + t.Error("network-policy.yaml should have deny-all egress by default") + } + + if _, ok := bc.GeneratedFiles["k8s/network-policy.yaml"]; !ok { + t.Error("k8s/network-policy.yaml not recorded in GeneratedFiles") + } +} + +func TestK8sStage_NetworkPolicyAllowEgress(t *testing.T) { + outDir := t.TempDir() + bc := pipeline.NewBuildContext(pipeline.PipelineOptions{OutputDir: outDir}) + bc.Spec = &agentspec.AgentSpec{ + AgentID: "web-agent", + Version: "0.2.0", + Runtime: &agentspec.RuntimeConfig{ + Image: "python:3.12-slim", + Entrypoint: []string{"python", "agent.py"}, + Port: 8080, + }, + } + + stage := &K8sStage{} + if err := stage.Execute(context.Background(), bc); err != nil { + t.Fatalf("Execute() error: %v", err) + } + + npPath := filepath.Join(outDir, "k8s", "network-policy.yaml") + data, err := os.ReadFile(npPath) + if err != nil { + t.Fatalf("reading network-policy.yaml: %v", err) + } + + content := string(data) + // Default is deny-all when no tools are set + if !strings.Contains(content, "egress: []") { + t.Error("expected deny-all egress for agent without network tools") + } +} diff --git a/forge-cli/build/policy_stage.go b/forge-cli/build/policy_stage.go new file mode 100644 index 0000000..0e6a882 --- /dev/null +++ b/forge-cli/build/policy_stage.go @@ -0,0 +1,43 @@ +package build + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/initializ/forge/forge-core/agentspec" + "github.com/initializ/forge/forge-core/pipeline" +) + +// PolicyStage generates the policy scaffold file. +type PolicyStage struct{} + +func (s *PolicyStage) Name() string { return "generate-policy-scaffold" } + +func (s *PolicyStage) Execute(ctx context.Context, bc *pipeline.BuildContext) error { + if bc.Spec.PolicyScaffold == nil { + bc.Spec.PolicyScaffold = &agentspec.PolicyScaffold{ + Guardrails: []agentspec.Guardrail{ + { + Type: "content_filter", + Config: map[string]any{"enabled": true}, + }, + }, + } + } + + data, err := json.MarshalIndent(bc.Spec.PolicyScaffold, "", " ") + if err != nil { + return fmt.Errorf("marshalling policy scaffold: %w", err) + } + + outPath := filepath.Join(bc.Opts.OutputDir, "policy-scaffold.json") + if err := os.WriteFile(outPath, data, 0644); err != nil { + return fmt.Errorf("writing policy-scaffold.json: %w", err) + } + + bc.AddFile("policy-scaffold.json", outPath) + return nil +} diff --git a/forge-cli/build/requirements_stage.go b/forge-cli/build/requirements_stage.go new file mode 100644 index 0000000..d4607e6 --- /dev/null +++ b/forge-cli/build/requirements_stage.go @@ -0,0 +1,78 @@ +package build + +import ( + "context" + + "github.com/initializ/forge/forge-core/agentspec" + "github.com/initializ/forge/forge-core/pipeline" + coreskills "github.com/initializ/forge/forge-core/skills" +) + +// RequirementsStage validates skill requirements and populates the agent spec. +type RequirementsStage struct{} + +func (s *RequirementsStage) Name() string { return "validate-requirements" } + +func (s *RequirementsStage) Execute(ctx context.Context, bc *pipeline.BuildContext) error { + if bc.SkillRequirements == nil { + return nil + } + + reqs, ok := bc.SkillRequirements.(*coreskills.AggregatedRequirements) + if !ok { + return nil + } + + // Check binaries — warnings only (may be installed in container) + binDiags := coreskills.BinDiagnostics(reqs.Bins) + for _, d := range binDiags { + bc.AddWarning(d.Message) + } + + // Populate agent spec requirements + if bc.Spec != nil { + bc.Spec.Requirements = &agentspec.AgentRequirements{ + Bins: reqs.Bins, + EnvRequired: reqs.EnvRequired, + EnvOptional: reqs.EnvOptional, + } + + // Auto-derive cli_execute config + derived := coreskills.DeriveCLIConfig(reqs) + if derived != nil && len(derived.AllowedBinaries) > 0 { + // Find existing cli_execute tool in spec and merge + found := false + for i, tool := range bc.Spec.Tools { + if tool.Name == "cli_execute" { + found = true + // Merge with existing ForgeMeta + if tool.ForgeMeta == nil { + tool.ForgeMeta = &agentspec.ForgeToolMeta{} + } + if len(tool.ForgeMeta.AllowedBinaries) == 0 { + tool.ForgeMeta.AllowedBinaries = derived.AllowedBinaries + } + if len(tool.ForgeMeta.EnvPassthrough) == 0 { + tool.ForgeMeta.EnvPassthrough = derived.EnvPassthrough + } + bc.Spec.Tools[i] = tool + break + } + } + + // If no cli_execute tool exists, add one with derived config + if !found { + bc.Spec.Tools = append(bc.Spec.Tools, agentspec.ToolSpec{ + Name: "cli_execute", + Category: "builtin", + ForgeMeta: &agentspec.ForgeToolMeta{ + AllowedBinaries: derived.AllowedBinaries, + EnvPassthrough: derived.EnvPassthrough, + }, + }) + } + } + } + + return nil +} diff --git a/forge-cli/build/requirements_stage_test.go b/forge-cli/build/requirements_stage_test.go new file mode 100644 index 0000000..65e9ddb --- /dev/null +++ b/forge-cli/build/requirements_stage_test.go @@ -0,0 +1,73 @@ +package build + +import ( + "context" + "testing" + + "github.com/initializ/forge/forge-core/agentspec" + "github.com/initializ/forge/forge-core/pipeline" + coreskills "github.com/initializ/forge/forge-core/skills" +) + +func TestRequirementsStage_NoSkills(t *testing.T) { + bc := pipeline.NewBuildContext(pipeline.PipelineOptions{}) + bc.Spec = &agentspec.AgentSpec{} + // SkillRequirements is nil + + stage := &RequirementsStage{} + if err := stage.Execute(context.Background(), bc); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should be a no-op + if bc.Spec.Requirements != nil { + t.Error("expected nil Requirements when no skills") + } +} + +func TestRequirementsStage_PopulatesSpec(t *testing.T) { + bc := pipeline.NewBuildContext(pipeline.PipelineOptions{}) + bc.Spec = &agentspec.AgentSpec{} + bc.SkillRequirements = &coreskills.AggregatedRequirements{ + Bins: []string{"curl", "jq"}, + EnvRequired: []string{"API_KEY"}, + EnvOptional: []string{"DEBUG"}, + } + + stage := &RequirementsStage{} + if err := stage.Execute(context.Background(), bc); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if bc.Spec.Requirements == nil { + t.Fatal("expected non-nil Requirements") + } + if len(bc.Spec.Requirements.Bins) != 2 { + t.Errorf("Bins = %v, want 2 items", bc.Spec.Requirements.Bins) + } + if len(bc.Spec.Requirements.EnvRequired) != 1 { + t.Errorf("EnvRequired = %v, want 1 item", bc.Spec.Requirements.EnvRequired) + } + if len(bc.Spec.Requirements.EnvOptional) != 1 { + t.Errorf("EnvOptional = %v, want 1 item", bc.Spec.Requirements.EnvOptional) + } + + // Should have auto-derived cli_execute tool + found := false + for _, tool := range bc.Spec.Tools { + if tool.Name == "cli_execute" { + found = true + if tool.ForgeMeta == nil { + t.Error("expected ForgeMeta on cli_execute tool") + } else { + if len(tool.ForgeMeta.AllowedBinaries) != 2 { + t.Errorf("AllowedBinaries = %v, want 2 items", tool.ForgeMeta.AllowedBinaries) + } + } + break + } + } + if !found { + t.Error("expected cli_execute tool to be auto-derived") + } +} diff --git a/forge-cli/build/skills_stage.go b/forge-cli/build/skills_stage.go new file mode 100644 index 0000000..5a024a0 --- /dev/null +++ b/forge-cli/build/skills_stage.go @@ -0,0 +1,67 @@ +package build + +import ( + "context" + "fmt" + "os" + "path/filepath" + + cliskills "github.com/initializ/forge/forge-cli/skills" + "github.com/initializ/forge/forge-core/pipeline" + coreskills "github.com/initializ/forge/forge-core/skills" +) + +// SkillsStage compiles skills.md into container artifacts. +type SkillsStage struct{} + +func (s *SkillsStage) Name() string { return "compile-skills" } + +func (s *SkillsStage) Execute(ctx context.Context, bc *pipeline.BuildContext) error { + // Determine skills file path + skillsPath := bc.Config.Skills.Path + if skillsPath == "" { + skillsPath = "skills.md" + } + if !filepath.IsAbs(skillsPath) { + skillsPath = filepath.Join(bc.Opts.WorkDir, skillsPath) + } + + // Skip silently if not found + if _, err := os.Stat(skillsPath); os.IsNotExist(err) { + return nil + } + + entries, _, err := cliskills.ParseFileWithMetadata(skillsPath) + if err != nil { + return fmt.Errorf("parsing skills file: %w", err) + } + + if len(entries) == 0 { + return nil + } + + // Aggregate skill requirements and store in build context + reqs := coreskills.AggregateRequirements(entries) + if len(reqs.Bins) > 0 || len(reqs.EnvRequired) > 0 || len(reqs.EnvOneOf) > 0 || len(reqs.EnvOptional) > 0 { + bc.SkillRequirements = reqs + } + + compiled, err := coreskills.Compile(entries) + if err != nil { + return fmt.Errorf("compiling skills: %w", err) + } + + if err := cliskills.WriteArtifacts(bc.Opts.OutputDir, compiled); err != nil { + return fmt.Errorf("writing skills artifacts: %w", err) + } + + bc.SkillsCount = compiled.Count + if bc.Spec != nil { + bc.Spec.SkillsSpecVersion = "agentskills-v1" + bc.Spec.ForgeSkillsExtVersion = "1.0" + } + + bc.AddFile("compiled/skills/skills.json", filepath.Join(bc.Opts.OutputDir, "compiled", "skills", "skills.json")) + bc.AddFile("compiled/prompt.txt", filepath.Join(bc.Opts.OutputDir, "compiled", "prompt.txt")) + return nil +} diff --git a/forge-cli/build/skills_stage_test.go b/forge-cli/build/skills_stage_test.go new file mode 100644 index 0000000..02251ba --- /dev/null +++ b/forge-cli/build/skills_stage_test.go @@ -0,0 +1,112 @@ +package build + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/initializ/forge/forge-core/agentspec" + "github.com/initializ/forge/forge-core/pipeline" + "github.com/initializ/forge/forge-core/types" +) + +func TestSkillsStage_NoFile(t *testing.T) { + tmpDir := t.TempDir() + bc := pipeline.NewBuildContext(pipeline.PipelineOptions{OutputDir: tmpDir, WorkDir: tmpDir}) + bc.Config = &types.ForgeConfig{AgentID: "test", Version: "1.0.0", Entrypoint: "python main.py"} + bc.Spec = &agentspec.AgentSpec{AgentID: "test"} + + stage := &SkillsStage{} + if err := stage.Execute(context.Background(), bc); err != nil { + t.Fatalf("Execute: %v", err) + } + if bc.SkillsCount != 0 { + t.Errorf("SkillsCount = %d, want 0", bc.SkillsCount) + } +} + +func TestSkillsStage_WithSkills(t *testing.T) { + tmpDir := t.TempDir() + + // Create a skills.md + skillsContent := `## Tool: web_search +Search the web for information. +**Input:** query: string +**Output:** results: []string + +## Tool: summarize +Summarize text content. +` + skillsPath := filepath.Join(tmpDir, "skills.md") + if err := os.WriteFile(skillsPath, []byte(skillsContent), 0644); err != nil { + t.Fatalf("writing skills.md: %v", err) + } + + outDir := filepath.Join(tmpDir, "output") + if err := os.MkdirAll(outDir, 0755); err != nil { + t.Fatalf("creating output dir: %v", err) + } + + bc := pipeline.NewBuildContext(pipeline.PipelineOptions{OutputDir: outDir, WorkDir: tmpDir}) + bc.Config = &types.ForgeConfig{AgentID: "test", Version: "1.0.0", Entrypoint: "python main.py"} + bc.Spec = &agentspec.AgentSpec{AgentID: "test"} + + stage := &SkillsStage{} + if err := stage.Execute(context.Background(), bc); err != nil { + t.Fatalf("Execute: %v", err) + } + + if bc.SkillsCount != 2 { + t.Errorf("SkillsCount = %d, want 2", bc.SkillsCount) + } + if bc.Spec.SkillsSpecVersion != "agentskills-v1" { + t.Errorf("SkillsSpecVersion = %q, want %q", bc.Spec.SkillsSpecVersion, "agentskills-v1") + } + + // Check artifacts exist + if _, err := os.Stat(filepath.Join(outDir, "compiled", "skills", "skills.json")); os.IsNotExist(err) { + t.Error("skills.json not created") + } + if _, err := os.Stat(filepath.Join(outDir, "compiled", "prompt.txt")); os.IsNotExist(err) { + t.Error("prompt.txt not created") + } +} + +func TestSkillsStage_CustomPath(t *testing.T) { + tmpDir := t.TempDir() + + // Create skills at custom path + skillsContent := `## Tool: custom_skill +A custom skill. +` + customDir := filepath.Join(tmpDir, "custom") + if err := os.MkdirAll(customDir, 0755); err != nil { + t.Fatalf("creating custom dir: %v", err) + } + if err := os.WriteFile(filepath.Join(customDir, "my-skills.md"), []byte(skillsContent), 0644); err != nil { + t.Fatalf("writing skills file: %v", err) + } + + outDir := filepath.Join(tmpDir, "output") + if err := os.MkdirAll(outDir, 0755); err != nil { + t.Fatalf("creating output dir: %v", err) + } + + bc := pipeline.NewBuildContext(pipeline.PipelineOptions{OutputDir: outDir, WorkDir: tmpDir}) + bc.Config = &types.ForgeConfig{ + AgentID: "test", + Version: "1.0.0", + Entrypoint: "python main.py", + Skills: types.SkillsRef{Path: "custom/my-skills.md"}, + } + bc.Spec = &agentspec.AgentSpec{AgentID: "test"} + + stage := &SkillsStage{} + if err := stage.Execute(context.Background(), bc); err != nil { + t.Fatalf("Execute: %v", err) + } + if bc.SkillsCount != 1 { + t.Errorf("SkillsCount = %d, want 1", bc.SkillsCount) + } +} diff --git a/forge-cli/build/tool_filter_stage.go b/forge-cli/build/tool_filter_stage.go new file mode 100644 index 0000000..c343f3e --- /dev/null +++ b/forge-cli/build/tool_filter_stage.go @@ -0,0 +1,87 @@ +package build + +import ( + "context" + + "github.com/initializ/forge/forge-core/pipeline" +) + +// Known dev tools that should be filtered in production builds. +var knownDevTools = map[string]bool{ + "local_shell": true, + "local_file_browser": true, + "debug_console": true, + "test_runner": true, +} + +// Known builtin tools. +var knownBuiltinTools = map[string]bool{ + "web_search": true, + "web-search": true, + "http_request": true, + "code_interpreter": true, + "text_generation": true, + "cli_execute": true, +} + +// Known adapter tools. +var knownAdapterTools = map[string]bool{ + "slack_notify": true, + "github_api": true, + "sendgrid_email": true, + "twilio_sms": true, + "openai_completion": true, + "anthropic_api": true, + "huggingface_api": true, + "google_vertex": true, + "aws_bedrock": true, + "azure_openai": true, +} + +// ToolFilterStage annotates tool categories and filters dev tools in production mode. +type ToolFilterStage struct{} + +func (s *ToolFilterStage) Name() string { return "filter-tools" } + +func (s *ToolFilterStage) Execute(ctx context.Context, bc *pipeline.BuildContext) error { + if bc.Spec == nil { + return nil + } + + bc.Spec.ToolInterfaceVersion = "1.0" + + // Annotate categories + for i := range bc.Spec.Tools { + name := bc.Spec.Tools[i].Name + switch { + case knownDevTools[name]: + bc.Spec.Tools[i].Category = "dev" + case knownBuiltinTools[name]: + bc.Spec.Tools[i].Category = "builtin" + case knownAdapterTools[name]: + bc.Spec.Tools[i].Category = "adapter" + default: + bc.Spec.Tools[i].Category = "custom" + } + } + + // Filter dev tools in prod mode + if bc.ProdMode { + filtered := bc.Spec.Tools[:0] + for _, t := range bc.Spec.Tools { + if t.Category != "dev" { + filtered = append(filtered, t) + } + } + bc.Spec.Tools = filtered + } + + // Count categories + counts := make(map[string]int) + for _, t := range bc.Spec.Tools { + counts[t.Category]++ + } + bc.ToolCategoryCounts = counts + + return nil +} diff --git a/forge-cli/build/tools_stage.go b/forge-cli/build/tools_stage.go new file mode 100644 index 0000000..b00edd6 --- /dev/null +++ b/forge-cli/build/tools_stage.go @@ -0,0 +1,59 @@ +package build + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/initializ/forge/forge-core/pipeline" +) + +// ToolsStage generates tool schema files for each tool in the spec. +type ToolsStage struct{} + +func (s *ToolsStage) Name() string { return "generate-tool-schemas" } + +func (s *ToolsStage) Execute(ctx context.Context, bc *pipeline.BuildContext) error { + if len(bc.Spec.Tools) == 0 { + return nil + } + + toolsDir := filepath.Join(bc.Opts.OutputDir, "tools") + if err := os.MkdirAll(toolsDir, 0755); err != nil { + return fmt.Errorf("creating tools directory: %w", err) + } + + for _, tool := range bc.Spec.Tools { + schema := map[string]any{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": tool.Name, + "description": tool.Description, + "type": "object", + "properties": map[string]any{}, + } + + if len(tool.InputSchema) > 0 { + var embedded map[string]any + if err := json.Unmarshal(tool.InputSchema, &embedded); err == nil { + schema["properties"] = embedded + } + } + + data, err := json.MarshalIndent(schema, "", " ") + if err != nil { + return fmt.Errorf("marshalling tool schema %s: %w", tool.Name, err) + } + + filename := tool.Name + ".schema.json" + outPath := filepath.Join(toolsDir, filename) + if err := os.WriteFile(outPath, data, 0644); err != nil { + return fmt.Errorf("writing tool schema %s: %w", tool.Name, err) + } + + bc.AddFile(filepath.Join("tools", filename), outPath) + } + + return nil +} diff --git a/forge-cli/build/tools_stage_test.go b/forge-cli/build/tools_stage_test.go new file mode 100644 index 0000000..bace0d3 --- /dev/null +++ b/forge-cli/build/tools_stage_test.go @@ -0,0 +1,54 @@ +package build + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/initializ/forge/forge-core/agentspec" + "github.com/initializ/forge/forge-core/pipeline" +) + +func TestToolsStage_NoTools(t *testing.T) { + outDir := t.TempDir() + bc := pipeline.NewBuildContext(pipeline.PipelineOptions{OutputDir: outDir}) + bc.Spec = &agentspec.AgentSpec{Tools: nil} + + stage := &ToolsStage{} + if err := stage.Execute(context.Background(), bc); err != nil { + t.Fatalf("Execute() error: %v", err) + } + + // tools/ directory should not exist + if _, err := os.Stat(filepath.Join(outDir, "tools")); !os.IsNotExist(err) { + t.Error("tools/ directory should not be created when no tools") + } +} + +func TestToolsStage_WithTools(t *testing.T) { + outDir := t.TempDir() + bc := pipeline.NewBuildContext(pipeline.PipelineOptions{OutputDir: outDir}) + bc.Spec = &agentspec.AgentSpec{ + Tools: []agentspec.ToolSpec{ + {Name: "web-search"}, + {Name: "sql-query"}, + }, + } + + stage := &ToolsStage{} + if err := stage.Execute(context.Background(), bc); err != nil { + t.Fatalf("Execute() error: %v", err) + } + + for _, name := range []string{"web-search.schema.json", "sql-query.schema.json"} { + path := filepath.Join(outDir, "tools", name) + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Errorf("expected file %s to exist", path) + } + } + + if len(bc.GeneratedFiles) != 2 { + t.Errorf("expected 2 generated files, got %d", len(bc.GeneratedFiles)) + } +} diff --git a/forge-cli/build/validate_stage.go b/forge-cli/build/validate_stage.go new file mode 100644 index 0000000..8dbe214 --- /dev/null +++ b/forge-cli/build/validate_stage.go @@ -0,0 +1,64 @@ +package build + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/initializ/forge/forge-core/pipeline" + "github.com/initializ/forge/forge-core/validate" +) + +// ValidateStage validates the generated output files. +type ValidateStage struct{} + +func (s *ValidateStage) Name() string { return "validate-output" } + +func (s *ValidateStage) Execute(ctx context.Context, bc *pipeline.BuildContext) error { + agentJSON := filepath.Join(bc.Opts.OutputDir, "agent.json") + data, err := os.ReadFile(agentJSON) + if err != nil { + return fmt.Errorf("reading agent.json for validation: %w", err) + } + + errs, err := validate.ValidateAgentSpec(data) + if err != nil { + return fmt.Errorf("validating agent.json: %w", err) + } + if len(errs) > 0 { + return fmt.Errorf("agent.json validation failed: %v", errs) + } + + requiredFiles := []string{"agent.json", "Dockerfile"} + for _, f := range requiredFiles { + path := filepath.Join(bc.Opts.OutputDir, f) + if _, err := os.Stat(path); os.IsNotExist(err) { + return fmt.Errorf("expected output file missing: %s", f) + } + } + + // Validate skills artifacts if skills were compiled + if bc.SkillsCount > 0 { + skillsArtifacts := []string{ + filepath.Join("compiled", "skills", "skills.json"), + filepath.Join("compiled", "prompt.txt"), + } + for _, f := range skillsArtifacts { + path := filepath.Join(bc.Opts.OutputDir, f) + if _, err := os.Stat(path); os.IsNotExist(err) { + return fmt.Errorf("expected skills artifact missing: %s", f) + } + } + } + + // Validate egress artifact if egress was resolved + if bc.EgressResolved != nil { + egressPath := filepath.Join(bc.Opts.OutputDir, "compiled", "egress_allowlist.json") + if _, err := os.Stat(egressPath); os.IsNotExist(err) { + return fmt.Errorf("expected egress artifact missing: compiled/egress_allowlist.json") + } + } + + return nil +} diff --git a/forge-cli/build/validate_stage_test.go b/forge-cli/build/validate_stage_test.go new file mode 100644 index 0000000..e5dd36f --- /dev/null +++ b/forge-cli/build/validate_stage_test.go @@ -0,0 +1,82 @@ +package build + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/initializ/forge/forge-core/agentspec" + "github.com/initializ/forge/forge-core/pipeline" +) + +func TestValidateStage_Valid(t *testing.T) { + outDir := t.TempDir() + bc := pipeline.NewBuildContext(pipeline.PipelineOptions{OutputDir: outDir}) + + spec := &agentspec.AgentSpec{ + ForgeVersion: "1.0", + AgentID: "test-agent", + Version: "0.1.0", + Name: "test-agent", + Runtime: &agentspec.RuntimeConfig{ + Image: "python:3.12-slim", + Entrypoint: []string{"python", "agent.py"}, + Port: 8080, + }, + } + bc.Spec = spec + + // Write agent.json + data, _ := json.MarshalIndent(spec, "", " ") + _ = os.WriteFile(filepath.Join(outDir, "agent.json"), data, 0644) + + // Write Dockerfile + _ = os.WriteFile(filepath.Join(outDir, "Dockerfile"), []byte("FROM python:3.12-slim\n"), 0644) + + stage := &ValidateStage{} + if err := stage.Execute(context.Background(), bc); err != nil { + t.Fatalf("Execute() error: %v", err) + } +} + +func TestValidateStage_InvalidSpec(t *testing.T) { + outDir := t.TempDir() + bc := pipeline.NewBuildContext(pipeline.PipelineOptions{OutputDir: outDir}) + bc.Spec = &agentspec.AgentSpec{} + + // Write invalid agent.json (missing required fields) + data := []byte(`{"forge_version": "1.0"}`) + _ = os.WriteFile(filepath.Join(outDir, "agent.json"), data, 0644) + _ = os.WriteFile(filepath.Join(outDir, "Dockerfile"), []byte("FROM ubuntu\n"), 0644) + + stage := &ValidateStage{} + err := stage.Execute(context.Background(), bc) + if err == nil { + t.Fatal("expected error for invalid agent.json") + } +} + +func TestValidateStage_MissingDockerfile(t *testing.T) { + outDir := t.TempDir() + bc := pipeline.NewBuildContext(pipeline.PipelineOptions{OutputDir: outDir}) + + spec := &agentspec.AgentSpec{ + ForgeVersion: "1.0", + AgentID: "test-agent", + Version: "0.1.0", + Name: "test-agent", + } + bc.Spec = spec + + data, _ := json.MarshalIndent(spec, "", " ") + _ = os.WriteFile(filepath.Join(outDir, "agent.json"), data, 0644) + // No Dockerfile + + stage := &ValidateStage{} + err := stage.Execute(context.Background(), bc) + if err == nil { + t.Fatal("expected error for missing Dockerfile") + } +} diff --git a/forge-cli/channels/config.go b/forge-cli/channels/config.go new file mode 100644 index 0000000..75f101e --- /dev/null +++ b/forge-cli/channels/config.go @@ -0,0 +1,28 @@ +package channels + +import ( + "fmt" + "os" + + "github.com/initializ/forge/forge-core/channels" + "gopkg.in/yaml.v3" +) + +// LoadChannelConfig reads and parses a channel adapter YAML config file. +func LoadChannelConfig(path string) (*channels.ChannelConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading channel config %s: %w", path, err) + } + + var cfg channels.ChannelConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parsing channel config %s: %w", path, err) + } + + if cfg.Adapter == "" { + return nil, fmt.Errorf("channel config %s: adapter is required", path) + } + + return &cfg, nil +} diff --git a/forge-cli/channels/integration_test.go b/forge-cli/channels/integration_test.go new file mode 100644 index 0000000..c24c016 --- /dev/null +++ b/forge-cli/channels/integration_test.go @@ -0,0 +1,192 @@ +//go:build integration + +package channels_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/initializ/forge/forge-core/a2a" + "github.com/initializ/forge/forge-core/channels" + + clichannels "github.com/initializ/forge/forge-cli/channels" + "github.com/initializ/forge/forge-plugins/channels/slack" + "github.com/initializ/forge/forge-plugins/channels/telegram" +) + +// mockA2AServer returns an httptest server that responds to tasks/send with a +// completed task containing the echoed message. +func mockA2AServer(t *testing.T) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req a2a.JSONRPCRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Logf("mock A2A: decode error: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + + var params a2a.SendTaskParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + t.Logf("mock A2A: params error: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + + // Echo back the user message as agent response + userText := "" + for _, p := range params.Message.Parts { + if p.Kind == a2a.PartKindText { + userText = p.Text + } + } + + task := a2a.Task{ + ID: params.ID, + Status: a2a.TaskStatus{ + State: a2a.TaskStateCompleted, + Message: &a2a.Message{ + Role: a2a.MessageRoleAgent, + Parts: []a2a.Part{a2a.NewTextPart("echo: " + userText)}, + }, + }, + } + + resp := a2a.NewResponse(req.ID, task) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) //nolint:errcheck + })) +} + +func TestSlackPlugin_MockA2A(t *testing.T) { + srv := mockA2AServer(t) + defer srv.Close() + + plugin := slack.New() + err := plugin.Init(channels.ChannelConfig{ + Adapter: "slack", + WebhookPort: 0, + Settings: map[string]string{ + "signing_secret": "test-secret", + "bot_token": "xoxb-test-token", + }, + }) + if err != nil { + t.Fatalf("Init: %v", err) + } + + // Test NormalizeEvent + rawEvent := []byte(`{ + "team_id": "T123", + "event": { + "type": "message", + "channel": "C456", + "user": "U789", + "text": "hello agent", + "ts": "1234567890.123456" + } + }`) + + event, err := plugin.NormalizeEvent(rawEvent) + if err != nil { + t.Fatalf("NormalizeEvent: %v", err) + } + + if event.Channel != "slack" { + t.Errorf("Channel = %q, want %q", event.Channel, "slack") + } + if event.Message != "hello agent" { + t.Errorf("Message = %q, want %q", event.Message, "hello agent") + } + if event.UserID != "U789" { + t.Errorf("UserID = %q, want %q", event.UserID, "U789") + } + + // Test Router round-trip with mock A2A + router := clichannels.NewRouter(srv.URL) + handler := router.Handler() + + resp, err := handler(context.Background(), event) + if err != nil { + t.Fatalf("handler: %v", err) + } + + if resp.Role != a2a.MessageRoleAgent { + t.Errorf("response role = %q, want %q", resp.Role, a2a.MessageRoleAgent) + } + + gotText := "" + for _, p := range resp.Parts { + if p.Kind == a2a.PartKindText { + gotText = p.Text + } + } + if gotText != "echo: hello agent" { + t.Errorf("response text = %q, want %q", gotText, "echo: hello agent") + } +} + +func TestTelegramPlugin_MockA2A(t *testing.T) { + srv := mockA2AServer(t) + defer srv.Close() + + plugin := telegram.New() + err := plugin.Init(channels.ChannelConfig{ + Adapter: "telegram", + Settings: map[string]string{ + "bot_token": "123456:ABC-DEF", + "mode": "polling", + }, + }) + if err != nil { + t.Fatalf("Init: %v", err) + } + + // Test NormalizeEvent + rawUpdate := []byte(`{ + "update_id": 100, + "message": { + "message_id": 42, + "from": {"id": 99}, + "chat": {"id": 555}, + "text": "hello telegram" + } + }`) + + event, err := plugin.NormalizeEvent(rawUpdate) + if err != nil { + t.Fatalf("NormalizeEvent: %v", err) + } + + if event.Channel != "telegram" { + t.Errorf("Channel = %q, want %q", event.Channel, "telegram") + } + if event.Message != "hello telegram" { + t.Errorf("Message = %q, want %q", event.Message, "hello telegram") + } + if event.WorkspaceID != "555" { + t.Errorf("WorkspaceID = %q, want %q", event.WorkspaceID, "555") + } + + // Test Router round-trip with mock A2A + router := clichannels.NewRouter(srv.URL) + handler := router.Handler() + + resp, err := handler(context.Background(), event) + if err != nil { + t.Fatalf("handler: %v", err) + } + + gotText := "" + for _, p := range resp.Parts { + if p.Kind == a2a.PartKindText { + gotText = p.Text + } + } + if gotText != "echo: hello telegram" { + t.Errorf("response text = %q, want %q", gotText, "echo: hello telegram") + } +} diff --git a/forge-cli/channels/router.go b/forge-cli/channels/router.go new file mode 100644 index 0000000..a338172 --- /dev/null +++ b/forge-cli/channels/router.go @@ -0,0 +1,112 @@ +package channels + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/initializ/forge/forge-core/a2a" + "github.com/initializ/forge/forge-core/channels" +) + +// Router forwards channel events to an A2A agent server via JSON-RPC over HTTP. +type Router struct { + agentURL string + client *http.Client +} + +// NewRouter creates a Router that forwards events to the A2A server at agentURL. +func NewRouter(agentURL string) *Router { + return &Router{ + agentURL: agentURL, + client: &http.Client{ + Timeout: 120 * time.Second, + }, + } +} + +// Handler returns an EventHandler suitable for passing to ChannelPlugin.Start(). +func (r *Router) Handler() channels.EventHandler { + return r.forwardToA2A +} + +// forwardToA2A sends a tasks/send JSON-RPC request to the A2A server and +// extracts the agent's response message from the returned task. +func (r *Router) forwardToA2A(ctx context.Context, event *channels.ChannelEvent) (*a2a.Message, error) { + taskID := fmt.Sprintf("%s-%s-%d", event.Channel, event.WorkspaceID, time.Now().UnixMilli()) + + params := a2a.SendTaskParams{ + ID: taskID, + Message: a2a.Message{ + Role: a2a.MessageRoleUser, + Parts: []a2a.Part{a2a.NewTextPart(event.Message)}, + }, + } + + paramsJSON, err := json.Marshal(params) + if err != nil { + return nil, fmt.Errorf("marshalling params: %w", err) + } + + rpcReq := a2a.JSONRPCRequest{ + JSONRPC: "2.0", + ID: taskID, + Method: "tasks/send", + Params: paramsJSON, + } + + body, err := json.Marshal(rpcReq) + if err != nil { + return nil, fmt.Errorf("marshalling request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, r.agentURL, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := r.client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("sending request to A2A server: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } + + var rpcResp a2a.JSONRPCResponse + if err := json.Unmarshal(respBody, &rpcResp); err != nil { + return nil, fmt.Errorf("parsing JSON-RPC response: %w", err) + } + + if rpcResp.Error != nil { + return nil, fmt.Errorf("A2A error %d: %s", rpcResp.Error.Code, rpcResp.Error.Message) + } + + // The result is a Task; extract status.message. + resultJSON, err := json.Marshal(rpcResp.Result) + if err != nil { + return nil, fmt.Errorf("re-marshalling result: %w", err) + } + + var task a2a.Task + if err := json.Unmarshal(resultJSON, &task); err != nil { + return nil, fmt.Errorf("parsing task from result: %w", err) + } + + if task.Status.Message != nil { + return task.Status.Message, nil + } + + return &a2a.Message{ + Role: a2a.MessageRoleAgent, + Parts: []a2a.Part{a2a.NewTextPart("(no response)")}, + }, nil +} diff --git a/forge-cli/channels/router_test.go b/forge-cli/channels/router_test.go new file mode 100644 index 0000000..9dc28ef --- /dev/null +++ b/forge-cli/channels/router_test.go @@ -0,0 +1,138 @@ +package channels + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/initializ/forge/forge-core/a2a" + "github.com/initializ/forge/forge-core/channels" +) + +func TestRouter_ForwardToA2A_Success(t *testing.T) { + // Mock A2A server + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req a2a.JSONRPCRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decoding request: %v", err) + } + + if req.Method != "tasks/send" { + t.Errorf("expected method tasks/send, got %s", req.Method) + } + + var params a2a.SendTaskParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + t.Fatalf("decoding params: %v", err) + } + + if params.Message.Parts[0].Text != "hello agent" { + t.Errorf("unexpected message text: %s", params.Message.Parts[0].Text) + } + + task := a2a.Task{ + ID: params.ID, + Status: a2a.TaskStatus{ + State: a2a.TaskStateCompleted, + Message: &a2a.Message{ + Role: a2a.MessageRoleAgent, + Parts: []a2a.Part{a2a.NewTextPart("hello user")}, + }, + }, + } + + resp := a2a.NewResponse(req.ID, task) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) //nolint:errcheck + })) + defer srv.Close() + + router := NewRouter(srv.URL) + event := &channels.ChannelEvent{ + Channel: "test", + WorkspaceID: "W123", + UserID: "U456", + Message: "hello agent", + } + + msg, err := router.forwardToA2A(context.Background(), event) + if err != nil { + t.Fatalf("forwardToA2A() error: %v", err) + } + + if msg.Role != a2a.MessageRoleAgent { + t.Errorf("expected agent role, got %s", msg.Role) + } + + if len(msg.Parts) != 1 || msg.Parts[0].Text != "hello user" { + t.Errorf("unexpected response text: %v", msg.Parts) + } +} + +func TestRouter_ForwardToA2A_Error(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req a2a.JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) //nolint:errcheck + + resp := a2a.NewErrorResponse(req.ID, a2a.ErrCodeInternal, "agent unavailable") + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) //nolint:errcheck + })) + defer srv.Close() + + router := NewRouter(srv.URL) + event := &channels.ChannelEvent{ + Channel: "test", + WorkspaceID: "W123", + Message: "hello", + } + + _, err := router.forwardToA2A(context.Background(), event) + if err == nil { + t.Fatal("expected error for A2A error response") + } +} + +func TestRouter_ForwardToA2A_NoMessage(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req a2a.JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) //nolint:errcheck + + // Task with no status message + task := a2a.Task{ + ID: "t1", + Status: a2a.TaskStatus{State: a2a.TaskStateCompleted}, + } + + resp := a2a.NewResponse(req.ID, task) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) //nolint:errcheck + })) + defer srv.Close() + + router := NewRouter(srv.URL) + event := &channels.ChannelEvent{ + Channel: "test", + WorkspaceID: "W123", + Message: "hello", + } + + msg, err := router.forwardToA2A(context.Background(), event) + if err != nil { + t.Fatalf("forwardToA2A() error: %v", err) + } + + if msg.Parts[0].Text != "(no response)" { + t.Errorf("expected fallback message, got %q", msg.Parts[0].Text) + } +} + +func TestRouter_Handler(t *testing.T) { + router := NewRouter("http://localhost:9999") + handler := router.Handler() + if handler == nil { + t.Fatal("Handler() returned nil") + } +} diff --git a/forge-cli/cmd/build.go b/forge-cli/cmd/build.go new file mode 100644 index 0000000..ad44e0c --- /dev/null +++ b/forge-cli/cmd/build.go @@ -0,0 +1,96 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/initializ/forge/forge-cli/build" + "github.com/initializ/forge/forge-cli/config" + "github.com/initializ/forge/forge-cli/plugins/crewai" + "github.com/initializ/forge/forge-cli/plugins/custom" + "github.com/initializ/forge/forge-cli/plugins/langchain" + "github.com/initializ/forge/forge-core/pipeline" + "github.com/initializ/forge/forge-core/plugins" + "github.com/initializ/forge/forge-core/validate" + "github.com/spf13/cobra" +) + +var buildCmd = &cobra.Command{ + Use: "build", + Short: "Build the agent container artifact", + RunE: runBuild, +} + +func runBuild(cmd *cobra.Command, args []string) error { + cfgPath := cfgFile + if !filepath.IsAbs(cfgPath) { + wd, err := os.Getwd() + if err != nil { + return fmt.Errorf("getting working directory: %w", err) + } + cfgPath = filepath.Join(wd, cfgPath) + } + + cfg, err := config.LoadForgeConfig(cfgPath) + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + // Pre-validate config + result := validate.ValidateForgeConfig(cfg) + if !result.IsValid() { + for _, e := range result.Errors { + fmt.Fprintf(os.Stderr, "ERROR: %s\n", e) + } + return fmt.Errorf("config validation failed: %d error(s)", len(result.Errors)) + } + + outDir := outputDir + if outDir == "." { + outDir = filepath.Join(filepath.Dir(cfgPath), ".forge-output") + } + if err := os.MkdirAll(outDir, 0755); err != nil { + return fmt.Errorf("creating output directory: %w", err) + } + + bc := pipeline.NewBuildContext(pipeline.PipelineOptions{ + WorkDir: filepath.Dir(cfgPath), + OutputDir: outDir, + ConfigPath: cfgPath, + }) + bc.Config = cfg + bc.Verbose = verbose + + reg := plugins.NewFrameworkRegistry() + reg.Register(&crewai.Plugin{}) + reg.Register(&langchain.Plugin{}) + reg.Register(&custom.Plugin{}) + + p := pipeline.New( + &build.FrameworkAdapterStage{Registry: reg}, + &build.AgentSpecStage{}, + &build.ToolsStage{}, + &build.ToolFilterStage{}, + &build.SkillsStage{}, + &build.RequirementsStage{}, + &build.PolicyStage{}, + &build.EgressStage{}, + &build.DockerfileStage{}, + &build.K8sStage{}, + &build.ValidateStage{}, + &build.ManifestStage{}, + ) + + if err := p.Run(context.Background(), bc); err != nil { + return fmt.Errorf("build failed: %w", err) + } + + for _, w := range bc.Warnings { + fmt.Fprintf(os.Stderr, "WARNING: %s\n", w) + } + + fmt.Printf("Build complete. Output: %s\n", outDir) + return nil +} diff --git a/forge-cli/cmd/build_test.go b/forge-cli/cmd/build_test.go new file mode 100644 index 0000000..6f710b3 --- /dev/null +++ b/forge-cli/cmd/build_test.go @@ -0,0 +1,109 @@ +package cmd + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestRunBuild_FullPipeline(t *testing.T) { + dir := t.TempDir() + cfgPath := writeTestForgeYAML(t, dir, ` +agent_id: test-agent +version: 0.1.0 +framework: langchain +entrypoint: python agent.py +model: + provider: openai + name: gpt-4 +tools: + - name: web-search + - name: sql-query +`) + + oldCfg := cfgFile + cfgFile = cfgPath + defer func() { cfgFile = oldCfg }() + + outDir := filepath.Join(dir, ".forge-output") + oldOut := outputDir + outputDir = outDir + defer func() { outputDir = oldOut }() + + err := runBuild(nil, nil) + if err != nil { + t.Fatalf("runBuild() error: %v", err) + } + + // Verify all expected output files + expectedFiles := []string{ + "agent.json", + "Dockerfile", + "policy-scaffold.json", + "build-manifest.json", + "tools/web-search.schema.json", + "tools/sql-query.schema.json", + "k8s/deployment.yaml", + "k8s/service.yaml", + } + + for _, f := range expectedFiles { + path := filepath.Join(outDir, f) + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Errorf("expected output file missing: %s", f) + } + } + + // Verify agent.json content + agentData, err := os.ReadFile(filepath.Join(outDir, "agent.json")) + if err != nil { + t.Fatalf("reading agent.json: %v", err) + } + var spec map[string]any + if err := json.Unmarshal(agentData, &spec); err != nil { + t.Fatalf("parsing agent.json: %v", err) + } + if spec["agent_id"] != "test-agent" { + t.Errorf("agent_id = %v, want test-agent", spec["agent_id"]) + } + + // Verify build-manifest.json + manifestData, err := os.ReadFile(filepath.Join(outDir, "build-manifest.json")) + if err != nil { + t.Fatalf("reading build-manifest.json: %v", err) + } + var manifest map[string]any + if err := json.Unmarshal(manifestData, &manifest); err != nil { + t.Fatalf("parsing build-manifest.json: %v", err) + } + files, ok := manifest["files"].([]any) + if !ok { + t.Fatal("manifest files is not an array") + } + if len(files) < 7 { + t.Errorf("expected at least 7 files in manifest, got %d", len(files)) + } +} + +func TestRunBuild_InvalidConfig(t *testing.T) { + dir := t.TempDir() + cfgPath := writeTestForgeYAML(t, dir, ` +agent_id: INVALID! +version: bad +entrypoint: "" +`) + + oldCfg := cfgFile + cfgFile = cfgPath + defer func() { cfgFile = oldCfg }() + + oldOut := outputDir + outputDir = filepath.Join(dir, ".forge-output") + defer func() { outputDir = oldOut }() + + err := runBuild(nil, nil) + if err == nil { + t.Fatal("expected error for invalid config") + } +} diff --git a/forge-cli/cmd/channel.go b/forge-cli/cmd/channel.go new file mode 100644 index 0000000..2641149 --- /dev/null +++ b/forge-cli/cmd/channel.go @@ -0,0 +1,347 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + + "github.com/initializ/forge/forge-cli/channels" + "github.com/initializ/forge/forge-cli/templates" + corechannels "github.com/initializ/forge/forge-core/channels" + "github.com/initializ/forge/forge-plugins/channels/slack" + "github.com/initializ/forge/forge-plugins/channels/telegram" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +var channelCmd = &cobra.Command{ + Use: "channel", + Short: "Manage agent communication channels", + Long: "Add and serve channel adapters (Slack, Telegram) for your agent.", +} + +var channelAddCmd = &cobra.Command{ + Use: "add ", + Short: "Add a channel adapter to the project", + Args: cobra.ExactArgs(1), + ValidArgs: []string{"slack", "telegram"}, + RunE: runChannelAdd, +} + +var channelServeCmd = &cobra.Command{ + Use: "serve ", + Short: "Run a standalone channel adapter (for container use)", + Args: cobra.ExactArgs(1), + ValidArgs: []string{"slack", "telegram"}, + RunE: runChannelServe, +} + +func init() { + channelCmd.AddCommand(channelAddCmd) + channelCmd.AddCommand(channelServeCmd) +} + +func runChannelAdd(cmd *cobra.Command, args []string) error { + adapter := args[0] + if adapter != "slack" && adapter != "telegram" { + return fmt.Errorf("unsupported adapter: %s (supported: slack, telegram)", adapter) + } + + wd, err := os.Getwd() + if err != nil { + return fmt.Errorf("getting working directory: %w", err) + } + + // 1. Generate {adapter}-config.yaml + cfgContent := generateChannelConfig(adapter) + cfgPath := filepath.Join(wd, adapter+"-config.yaml") + if err := os.WriteFile(cfgPath, []byte(cfgContent), 0644); err != nil { + return fmt.Errorf("writing %s: %w", cfgPath, err) + } + fmt.Printf("Created %s-config.yaml\n", adapter) + + // 2. Append env vars to .env + envPath := filepath.Join(wd, ".env") + envContent := generateEnvVars(adapter) + f, err := os.OpenFile(envPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("opening .env: %w", err) + } + if _, err := f.WriteString(envContent); err != nil { + _ = f.Close() + return fmt.Errorf("writing .env: %w", err) + } + _ = f.Close() + fmt.Println("Updated .env with placeholder variables") + + // 3. Update forge.yaml — add channel to channels list + forgePath := filepath.Join(wd, "forge.yaml") + if err := addChannelToForgeYAML(forgePath, adapter); err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not update forge.yaml: %v\n", err) + } else { + fmt.Printf("Added %q to channels in forge.yaml\n", adapter) + } + + // 4. Update egress config for channel adapter + if err := addChannelEgressToForgeYAML(forgePath, adapter); err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not update egress in forge.yaml: %v\n", err) + } else { + fmt.Printf("Updated egress config for %q channel\n", adapter) + } + + // 5. Print setup instructions + printSetupInstructions(adapter) + return nil +} + +func runChannelServe(cmd *cobra.Command, args []string) error { + adapter := args[0] + if adapter != "slack" && adapter != "telegram" { + return fmt.Errorf("unsupported adapter: %s (supported: slack, telegram)", adapter) + } + + // Load channel config + wd, err := os.Getwd() + if err != nil { + return fmt.Errorf("getting working directory: %w", err) + } + + cfgPath := filepath.Join(wd, adapter+"-config.yaml") + cfg, err := channels.LoadChannelConfig(cfgPath) + if err != nil { + return fmt.Errorf("loading channel config: %w", err) + } + + // AGENT_URL is required + agentURL := os.Getenv("AGENT_URL") + if agentURL == "" { + return fmt.Errorf("AGENT_URL environment variable is required") + } + + // Create plugin + plugin := createPlugin(adapter) + if plugin == nil { + return fmt.Errorf("unknown adapter: %s", adapter) + } + + if err := plugin.Init(*cfg); err != nil { + return fmt.Errorf("initialising %s plugin: %w", adapter, err) + } + + // Create router + router := channels.NewRouter(agentURL) + + // Signal handling + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigCh + fmt.Fprintln(os.Stderr, "\nShutting down channel adapter...") + cancel() + }() + + fmt.Fprintf(os.Stderr, "Starting %s adapter (agent: %s)\n", adapter, agentURL) + return plugin.Start(ctx, router.Handler()) +} + +// createPlugin returns a new ChannelPlugin for the named adapter. +func createPlugin(name string) corechannels.ChannelPlugin { + switch name { + case "slack": + return slack.New() + case "telegram": + return telegram.New() + default: + return nil + } +} + +// defaultRegistry returns a pre-loaded channel plugin registry. +func defaultRegistry() *corechannels.Registry { + r := corechannels.NewRegistry() + r.Register(slack.New()) + r.Register(telegram.New()) + return r +} + +func generateChannelConfig(adapter string) string { + content, err := templates.GetInitTemplate(adapter + "-config.yaml.tmpl") + if err != nil { + // Fallback for unknown adapters + return "" + } + return content +} + +func generateEnvVars(adapter string) string { + content, err := templates.GetInitTemplate("env-" + adapter + ".tmpl") + if err != nil { + return "" + } + return content +} + +func addChannelToForgeYAML(path, adapter string) error { + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("reading forge.yaml: %w", err) + } + + var doc map[string]any + if err := yaml.Unmarshal(data, &doc); err != nil { + return fmt.Errorf("parsing forge.yaml: %w", err) + } + + // Get or create channels list + var chList []string + if existing, ok := doc["channels"]; ok { + if arr, ok := existing.([]any); ok { + for _, v := range arr { + if s, ok := v.(string); ok { + chList = append(chList, s) + } + } + } + } + + // Check if adapter already in list + for _, ch := range chList { + if ch == adapter { + return nil // already present + } + } + + chList = append(chList, adapter) + + // Convert back to []any for YAML marshalling + chAny := make([]any, len(chList)) + for i, s := range chList { + chAny[i] = s + } + doc["channels"] = chAny + + out, err := yaml.Marshal(doc) + if err != nil { + return fmt.Errorf("marshalling forge.yaml: %w", err) + } + + return os.WriteFile(path, out, 0644) +} + +func addChannelEgressToForgeYAML(path, adapter string) error { + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("reading forge.yaml: %w", err) + } + + var doc map[string]any + if err := yaml.Unmarshal(data, &doc); err != nil { + return fmt.Errorf("parsing forge.yaml: %w", err) + } + + // Get or create egress map + egressRaw, ok := doc["egress"] + if !ok { + egressRaw = map[string]any{} + } + egressMap, ok := egressRaw.(map[string]any) + if !ok { + egressMap = map[string]any{} + } + + switch adapter { + case "slack": + // Add "slack" to egress.capabilities + var caps []string + if existing, ok := egressMap["capabilities"]; ok { + if arr, ok := existing.([]any); ok { + for _, v := range arr { + if s, ok := v.(string); ok { + caps = append(caps, s) + } + } + } + } + // Check if already present + for _, c := range caps { + if c == "slack" { + return nil // already present + } + } + caps = append(caps, "slack") + capsAny := make([]any, len(caps)) + for i, s := range caps { + capsAny[i] = s + } + egressMap["capabilities"] = capsAny + + case "telegram": + // Add "api.telegram.org" to egress.allowed_domains + var domains []string + if existing, ok := egressMap["allowed_domains"]; ok { + if arr, ok := existing.([]any); ok { + for _, v := range arr { + if s, ok := v.(string); ok { + domains = append(domains, s) + } + } + } + } + // Check if already present + for _, d := range domains { + if d == "api.telegram.org" { + return nil // already present + } + } + domains = append(domains, "api.telegram.org") + domainsAny := make([]any, len(domains)) + for i, s := range domains { + domainsAny[i] = s + } + egressMap["allowed_domains"] = domainsAny + } + + doc["egress"] = egressMap + + out, err := yaml.Marshal(doc) + if err != nil { + return fmt.Errorf("marshalling forge.yaml: %w", err) + } + + return os.WriteFile(path, out, 0644) +} + +func printSetupInstructions(adapter string) { + fmt.Println() + switch adapter { + case "slack": + fmt.Println("Slack setup instructions:") + fmt.Println(" 1. Create a Slack App at https://api.slack.com/apps") + fmt.Println(" 2. Enable Event Subscriptions and set the Request URL to") + fmt.Println(" https://:3000/slack/events") + fmt.Println(" 3. Subscribe to bot events: message.channels, message.im") + fmt.Println(" 4. Install the app to your workspace") + fmt.Println(" 5. Copy the Signing Secret and Bot Token into .env") + fmt.Println(" 6. Run: forge run --with slack") + case "telegram": + fmt.Println("Telegram setup instructions:") + fmt.Println(" 1. Create a bot via @BotFather on Telegram") + fmt.Println(" 2. Copy the bot token into .env") + fmt.Println(" 3. Run: forge run --with telegram") + fmt.Println() + fmt.Println(" For webhook mode (requires public URL):") + fmt.Println(" Set mode: webhook in telegram-config.yaml") + fmt.Println(" Set your webhook URL via Telegram Bot API") + } + fmt.Println() + fmt.Println(strings.Repeat("─", 40)) + fmt.Printf("Config: %s-config.yaml\n", adapter) + fmt.Printf("Test: forge run --with %s\n", adapter) +} diff --git a/forge-cli/cmd/channel_test.go b/forge-cli/cmd/channel_test.go new file mode 100644 index 0000000..db77dc6 --- /dev/null +++ b/forge-cli/cmd/channel_test.go @@ -0,0 +1,391 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestChannelAddSlack(t *testing.T) { + dir := t.TempDir() + origDir, _ := os.Getwd() + os.Chdir(dir) //nolint:errcheck + defer os.Chdir(origDir) //nolint:errcheck + + // Create a minimal forge.yaml + writeTestForgeYAML(t, dir, ` +agent_id: test-agent +version: 0.1.0 +framework: custom +entrypoint: python agent.py +channels: + - a2a +`) + + err := runChannelAdd(nil, []string{"slack"}) + if err != nil { + t.Fatalf("runChannelAdd(slack) error: %v", err) + } + + // Check slack-config.yaml was created + cfgPath := filepath.Join(dir, "slack-config.yaml") + if _, err := os.Stat(cfgPath); os.IsNotExist(err) { + t.Error("slack-config.yaml not created") + } + + data, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatalf("reading slack-config.yaml: %v", err) + } + if !strings.Contains(string(data), "adapter: slack") { + t.Error("slack-config.yaml missing adapter field") + } + + // Check .env was updated + envPath := filepath.Join(dir, ".env") + envData, err := os.ReadFile(envPath) + if err != nil { + t.Fatalf("reading .env: %v", err) + } + if !strings.Contains(string(envData), "SLACK_SIGNING_SECRET") { + t.Error(".env missing SLACK_SIGNING_SECRET") + } + if !strings.Contains(string(envData), "SLACK_BOT_TOKEN") { + t.Error(".env missing SLACK_BOT_TOKEN") + } + + // Check forge.yaml was updated with slack channel + forgeData, err := os.ReadFile(filepath.Join(dir, "forge.yaml")) + if err != nil { + t.Fatalf("reading forge.yaml: %v", err) + } + var doc map[string]any + if err := yaml.Unmarshal(forgeData, &doc); err != nil { + t.Fatalf("parsing forge.yaml: %v", err) + } + chList, ok := doc["channels"].([]any) + if !ok { + t.Fatal("channels not found in forge.yaml") + } + found := false + for _, ch := range chList { + if ch == "slack" { + found = true + break + } + } + if !found { + t.Error("slack not added to channels list") + } +} + +func TestChannelAddTelegram(t *testing.T) { + dir := t.TempDir() + origDir, _ := os.Getwd() + os.Chdir(dir) //nolint:errcheck + defer os.Chdir(origDir) //nolint:errcheck + + writeTestForgeYAML(t, dir, ` +agent_id: test-agent +version: 0.1.0 +framework: custom +entrypoint: python agent.py +`) + + err := runChannelAdd(nil, []string{"telegram"}) + if err != nil { + t.Fatalf("runChannelAdd(telegram) error: %v", err) + } + + // Check telegram-config.yaml was created + cfgPath := filepath.Join(dir, "telegram-config.yaml") + if _, err := os.Stat(cfgPath); os.IsNotExist(err) { + t.Error("telegram-config.yaml not created") + } + + data, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatalf("reading telegram-config.yaml: %v", err) + } + if !strings.Contains(string(data), "adapter: telegram") { + t.Error("telegram-config.yaml missing adapter field") + } + if !strings.Contains(string(data), "mode: polling") { + t.Error("telegram-config.yaml missing polling mode default") + } + + // Check .env + envData, err := os.ReadFile(filepath.Join(dir, ".env")) + if err != nil { + t.Fatalf("reading .env: %v", err) + } + if !strings.Contains(string(envData), "TELEGRAM_BOT_TOKEN") { + t.Error(".env missing TELEGRAM_BOT_TOKEN") + } +} + +func TestChannelAddUnsupported(t *testing.T) { + err := runChannelAdd(nil, []string{"discord"}) + if err == nil { + t.Fatal("expected error for unsupported adapter") + } +} + +func TestChannelAddIdempotent(t *testing.T) { + dir := t.TempDir() + origDir, _ := os.Getwd() + os.Chdir(dir) //nolint:errcheck + defer os.Chdir(origDir) //nolint:errcheck + + writeTestForgeYAML(t, dir, ` +agent_id: test-agent +version: 0.1.0 +framework: custom +entrypoint: python agent.py +channels: + - slack +`) + + // Adding slack when it's already in the channels list should succeed + err := runChannelAdd(nil, []string{"slack"}) + if err != nil { + t.Fatalf("runChannelAdd(slack) second time error: %v", err) + } + + // Verify slack appears only once + forgeData, err := os.ReadFile(filepath.Join(dir, "forge.yaml")) + if err != nil { + t.Fatalf("reading forge.yaml: %v", err) + } + var doc map[string]any + yaml.Unmarshal(forgeData, &doc) //nolint:errcheck + chList := doc["channels"].([]any) + count := 0 + for _, ch := range chList { + if ch == "slack" { + count++ + } + } + if count != 1 { + t.Errorf("slack appears %d times, want 1", count) + } +} + +func TestChannelServeNoAgentURL(t *testing.T) { + dir := t.TempDir() + origDir, _ := os.Getwd() + os.Chdir(dir) //nolint:errcheck + defer os.Chdir(origDir) //nolint:errcheck + + // Write a valid channel config + _ = os.WriteFile(filepath.Join(dir, "slack-config.yaml"), []byte(` +adapter: slack +webhook_port: 3000 +settings: + signing_secret: test + bot_token: test +`), 0644) //nolint:errcheck + + t.Setenv("AGENT_URL", "") + + err := runChannelServe(nil, []string{"slack"}) + if err == nil { + t.Fatal("expected error when AGENT_URL is not set") + } +} + +func TestAddChannelToForgeYAML(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "forge.yaml") + + _ = os.WriteFile(path, []byte(` +agent_id: test +version: 0.1.0 +entrypoint: run.py +`), 0644) //nolint:errcheck + + err := addChannelToForgeYAML(path, "slack") + if err != nil { + t.Fatalf("addChannelToForgeYAML() error: %v", err) + } + + data, _ := os.ReadFile(path) + var doc map[string]any + yaml.Unmarshal(data, &doc) //nolint:errcheck + + chList, ok := doc["channels"].([]any) + if !ok { + t.Fatal("channels not found") + } + if len(chList) != 1 || chList[0] != "slack" { + t.Errorf("channels = %v, want [slack]", chList) + } +} + +func TestChannelAddSlack_UpdatesEgress(t *testing.T) { + dir := t.TempDir() + origDir, _ := os.Getwd() + os.Chdir(dir) //nolint:errcheck + defer os.Chdir(origDir) //nolint:errcheck + + writeTestForgeYAML(t, dir, ` +agent_id: test-agent +version: 0.1.0 +framework: custom +entrypoint: python agent.py +`) + + err := runChannelAdd(nil, []string{"slack"}) + if err != nil { + t.Fatalf("runChannelAdd(slack) error: %v", err) + } + + // Check egress.capabilities contains "slack" + forgeData, err := os.ReadFile(filepath.Join(dir, "forge.yaml")) + if err != nil { + t.Fatalf("reading forge.yaml: %v", err) + } + var doc map[string]any + if err := yaml.Unmarshal(forgeData, &doc); err != nil { + t.Fatalf("parsing forge.yaml: %v", err) + } + egressMap, ok := doc["egress"].(map[string]any) + if !ok { + t.Fatal("egress not found in forge.yaml") + } + caps, ok := egressMap["capabilities"].([]any) + if !ok { + t.Fatal("egress.capabilities not found") + } + found := false + for _, c := range caps { + if c == "slack" { + found = true + break + } + } + if !found { + t.Errorf("slack not in egress.capabilities: %v", caps) + } +} + +func TestChannelAddTelegram_UpdatesEgress(t *testing.T) { + dir := t.TempDir() + origDir, _ := os.Getwd() + os.Chdir(dir) //nolint:errcheck + defer os.Chdir(origDir) //nolint:errcheck + + writeTestForgeYAML(t, dir, ` +agent_id: test-agent +version: 0.1.0 +framework: custom +entrypoint: python agent.py +`) + + err := runChannelAdd(nil, []string{"telegram"}) + if err != nil { + t.Fatalf("runChannelAdd(telegram) error: %v", err) + } + + // Check egress.allowed_domains contains "api.telegram.org" + forgeData, err := os.ReadFile(filepath.Join(dir, "forge.yaml")) + if err != nil { + t.Fatalf("reading forge.yaml: %v", err) + } + var doc map[string]any + if err := yaml.Unmarshal(forgeData, &doc); err != nil { + t.Fatalf("parsing forge.yaml: %v", err) + } + egressMap, ok := doc["egress"].(map[string]any) + if !ok { + t.Fatal("egress not found in forge.yaml") + } + domains, ok := egressMap["allowed_domains"].([]any) + if !ok { + t.Fatal("egress.allowed_domains not found") + } + found := false + for _, d := range domains { + if d == "api.telegram.org" { + found = true + break + } + } + if !found { + t.Errorf("api.telegram.org not in egress.allowed_domains: %v", domains) + } +} + +func TestAddChannelEgressIdempotent(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "forge.yaml") + + _ = os.WriteFile(path, []byte(` +agent_id: test +version: 0.1.0 +entrypoint: run.py +egress: + capabilities: + - slack + allowed_domains: + - api.telegram.org +`), 0644) //nolint:errcheck + + // Adding slack again should not duplicate + if err := addChannelEgressToForgeYAML(path, "slack"); err != nil { + t.Fatalf("addChannelEgressToForgeYAML(slack) error: %v", err) + } + // Adding telegram again should not duplicate + if err := addChannelEgressToForgeYAML(path, "telegram"); err != nil { + t.Fatalf("addChannelEgressToForgeYAML(telegram) error: %v", err) + } + + data, _ := os.ReadFile(path) + var doc map[string]any + yaml.Unmarshal(data, &doc) //nolint:errcheck + egressMap := doc["egress"].(map[string]any) + + // Check slack appears only once + caps := egressMap["capabilities"].([]any) + count := 0 + for _, c := range caps { + if c == "slack" { + count++ + } + } + if count != 1 { + t.Errorf("slack appears %d times in capabilities, want 1", count) + } + + // Check telegram domain appears only once + domains := egressMap["allowed_domains"].([]any) + count = 0 + for _, d := range domains { + if d == "api.telegram.org" { + count++ + } + } + if count != 1 { + t.Errorf("api.telegram.org appears %d times in allowed_domains, want 1", count) + } +} + +func TestGenerateChannelConfig(t *testing.T) { + slack := generateChannelConfig("slack") + if !strings.Contains(slack, "adapter: slack") { + t.Error("slack config missing adapter") + } + + tg := generateChannelConfig("telegram") + if !strings.Contains(tg, "adapter: telegram") { + t.Error("telegram config missing adapter") + } + + unknown := generateChannelConfig("discord") + if unknown != "" { + t.Errorf("unknown adapter should return empty, got %q", unknown) + } +} diff --git a/forge-cli/cmd/export.go b/forge-cli/cmd/export.go new file mode 100644 index 0000000..591faa0 --- /dev/null +++ b/forge-cli/cmd/export.go @@ -0,0 +1,203 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/initializ/forge/forge-cli/config" + "github.com/initializ/forge/forge-core/agentspec" + "github.com/initializ/forge/forge-core/export" + "github.com/initializ/forge/forge-core/validate" + "github.com/spf13/cobra" +) + +var ( + exportOutput string + exportPretty bool + exportIncludeSchemas bool + exportSimulateImport bool + exportDevMode bool +) + +var exportCmd = &cobra.Command{ + Use: "export", + Short: "Export the agent specification for Command platform import", + Long: "Export produces a standalone AgentSpec JSON file with metadata for importing into the Command platform.", + RunE: runExport, +} + +func init() { + exportCmd.Flags().StringVar(&exportOutput, "output", "", "output file path (default: {agent_id}-forge.json)") + exportCmd.Flags().BoolVar(&exportPretty, "pretty", false, "format JSON with indentation") + exportCmd.Flags().BoolVar(&exportIncludeSchemas, "include-schemas", false, "embed tool schemas inline from build output") + exportCmd.Flags().BoolVar(&exportSimulateImport, "simulate-import", false, "print simulated Command import result to stdout") + exportCmd.Flags().BoolVar(&exportDevMode, "dev", false, "include dev-category tools in export") +} + +func runExport(cmd *cobra.Command, args []string) error { + // 1. Resolve config path + cfgPath := cfgFile + if !filepath.IsAbs(cfgPath) { + wd, err := os.Getwd() + if err != nil { + return fmt.Errorf("getting working directory: %w", err) + } + cfgPath = filepath.Join(wd, cfgPath) + } + + // 2. Load config (needed for agent_id default filename) + cfg, err := config.LoadForgeConfig(cfgPath) + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + // 3. Determine output dir and ensure build output exists + outDir := outputDir + if outDir == "." { + outDir = filepath.Join(filepath.Dir(cfgPath), ".forge-output") + } + + if err := ensureBuildOutput(outDir, cfgPath); err != nil { + return err + } + + // 4. Read agent.json from build output + agentJSONPath := filepath.Join(outDir, "agent.json") + agentData, err := os.ReadFile(agentJSONPath) + if err != nil { + return fmt.Errorf("reading agent.json: %w", err) + } + + // 5. Validate agent.json against schema + schemaErrs, err := validate.ValidateAgentSpec(agentData) + if err != nil { + return fmt.Errorf("validating agent.json: %w", err) + } + if len(schemaErrs) > 0 { + for _, e := range schemaErrs { + fmt.Fprintf(os.Stderr, "ERROR: agent.json: %s\n", e) + } + return fmt.Errorf("agent.json schema validation failed: %d error(s)", len(schemaErrs)) + } + + // 6. Unmarshal into AgentSpec + var spec agentspec.AgentSpec + if err := json.Unmarshal(agentData, &spec); err != nil { + return fmt.Errorf("parsing agent.json: %w", err) + } + + // 6a. Export validation + exportVal := export.ValidateForExport(&spec, exportDevMode) + for _, w := range exportVal.Warnings { + fmt.Fprintf(os.Stderr, "WARNING: %s\n", w) + } + if len(exportVal.Errors) > 0 { + for _, e := range exportVal.Errors { + fmt.Fprintf(os.Stderr, "ERROR: %s\n", e) + } + return fmt.Errorf("export validation failed: %s", exportVal.Errors[0]) + } + + // 7. Run Command compatibility validation + compatResult := validate.ValidateCommandCompat(&spec) + for _, w := range compatResult.Warnings { + fmt.Fprintf(os.Stderr, "WARNING: %s\n", w) + } + for _, e := range compatResult.Errors { + fmt.Fprintf(os.Stderr, "ERROR: %s\n", e) + } + if !compatResult.IsValid() { + return fmt.Errorf("command compatibility check failed: %d error(s)", len(compatResult.Errors)) + } + + // 8. Include schemas if requested + if exportIncludeSchemas { + if err := embedToolSchemas(outDir, &spec); err != nil { + return fmt.Errorf("embedding tool schemas: %w", err) + } + } + + // 9. Simulate import mode + if exportSimulateImport { + simResult := validate.SimulateImport(&spec) + var simData []byte + simData, err = json.MarshalIndent(simResult, "", " ") + if err != nil { + return fmt.Errorf("marshalling import simulation: %w", err) + } + fmt.Println(string(simData)) + return nil + } + + // 10. Read allowlist domains from build output + var allowlistDomains []string + allowlistPath := filepath.Join(outDir, "compiled", "egress_allowlist.json") + if data, readErr := os.ReadFile(allowlistPath); readErr == nil { + var allowlist map[string]any + if json.Unmarshal(data, &allowlist) == nil { + if domains, ok := allowlist["all_domains"].([]any); ok { + for _, d := range domains { + if s, ok := d.(string); ok { + allowlistDomains = append(allowlistDomains, s) + } + } + } + } + } + + // 11. Build export envelope + envelope, err := export.BuildEnvelope(&spec, allowlistDomains, appVersion) + if err != nil { + return fmt.Errorf("building export envelope: %w", err) + } + + // 12. Marshal final output + var exportData []byte + if exportPretty { + exportData, err = json.MarshalIndent(envelope, "", " ") + } else { + exportData, err = json.Marshal(envelope) + } + if err != nil { + return fmt.Errorf("marshalling export: %w", err) + } + exportData = append(exportData, '\n') + + // 13. Determine output filename + outFile := exportOutput + if outFile == "" { + outFile = fmt.Sprintf("%s-forge.json", cfg.AgentID) + } + + if err := os.WriteFile(outFile, exportData, 0644); err != nil { + return fmt.Errorf("writing export file: %w", err) + } + + fmt.Printf("Exported: %s\n", outFile) + return nil +} + +// embedToolSchemas reads tool schema files from .forge-output/tools/ and +// merges them into the spec's tool InputSchema fields. +func embedToolSchemas(outDir string, spec *agentspec.AgentSpec) error { + toolsDir := filepath.Join(outDir, "tools") + for i := range spec.Tools { + schemaFile := filepath.Join(toolsDir, spec.Tools[i].Name+".schema.json") + data, err := os.ReadFile(schemaFile) + if err != nil { + if os.IsNotExist(err) { + continue + } + return fmt.Errorf("reading schema for tool %s: %w", spec.Tools[i].Name, err) + } + // Validate it's valid JSON + var js json.RawMessage + if err := json.Unmarshal(data, &js); err != nil { + return fmt.Errorf("invalid JSON in schema for tool %s: %w", spec.Tools[i].Name, err) + } + spec.Tools[i].InputSchema = js + } + return nil +} diff --git a/forge-cli/cmd/export_test.go b/forge-cli/cmd/export_test.go new file mode 100644 index 0000000..f53dd12 --- /dev/null +++ b/forge-cli/cmd/export_test.go @@ -0,0 +1,738 @@ +package cmd + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/initializ/forge/forge-core/validate" +) + +// testAgentJSON returns a valid agent.json that passes schema validation. +func testAgentJSON() string { + spec := map[string]any{ + "forge_version": "1.0", + "agent_id": "test-agent", + "version": "0.1.0", + "name": "Test Agent", + "description": "A test agent", + "tool_interface_version": "1.0", + "skills_spec_version": "agentskills-v1", + "egress_profile": "standard", + "egress_mode": "allowlist", + "runtime": map[string]any{ + "image": "python:3.11-slim", + "port": 8080, + }, + "tools": []map[string]any{ + { + "name": "web-search", + "description": "Search the web", + "category": "builtin", + "input_schema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "query": map[string]any{"type": "string"}, + }, + }, + }, + }, + "model": map[string]any{ + "provider": "openai", + "name": "gpt-4", + }, + "a2a": map[string]any{ + "capabilities": map[string]any{ + "streaming": true, + }, + }, + } + data, _ := json.MarshalIndent(spec, "", " ") + return string(data) +} + +// setupExportTest creates a temp directory with forge.yaml and .forge-output/agent.json. +func setupExportTest(t *testing.T) (dir string, cleanup func()) { + t.Helper() + dir = t.TempDir() + + // Write forge.yaml + writeTestForgeYAML(t, dir, ` +agent_id: test-agent +version: 0.1.0 +framework: custom +entrypoint: python agent.py +model: + provider: openai + name: gpt-4 +tools: + - name: web-search +`) + + // Create .forge-output directory + outDir := filepath.Join(dir, ".forge-output") + if err := os.MkdirAll(outDir, 0755); err != nil { + t.Fatalf("creating .forge-output: %v", err) + } + + // Write agent.json + if err := os.WriteFile(filepath.Join(outDir, "agent.json"), []byte(testAgentJSON()), 0644); err != nil { + t.Fatalf("writing agent.json: %v", err) + } + + // Write build-manifest.json (so ensureBuildOutput doesn't trigger a build) + manifest := `{"built_at":"2025-01-01T00:00:00Z"}` + if err := os.WriteFile(filepath.Join(outDir, "build-manifest.json"), []byte(manifest), 0644); err != nil { + t.Fatalf("writing build-manifest.json: %v", err) + } + + cleanup = func() { + cfgFile = "forge.yaml" + outputDir = "." + exportOutput = "" + exportPretty = false + exportIncludeSchemas = false + exportSimulateImport = false + exportDevMode = false + } + + return dir, cleanup +} + +func TestRunExport_BasicExport(t *testing.T) { + dir, cleanup := setupExportTest(t) + defer cleanup() + + cfgFile = filepath.Join(dir, "forge.yaml") + outputDir = "." + exportOutput = filepath.Join(dir, "output.json") + + err := runExport(nil, nil) + if err != nil { + t.Fatalf("runExport() error: %v", err) + } + + // Verify output file exists + data, err := os.ReadFile(filepath.Join(dir, "output.json")) + if err != nil { + t.Fatalf("reading output: %v", err) + } + + var result map[string]any + if err := json.Unmarshal(data, &result); err != nil { + t.Fatalf("parsing output: %v", err) + } + + if result["agent_id"] != "test-agent" { + t.Errorf("agent_id = %v, want test-agent", result["agent_id"]) + } + if result["_forge_export_meta"] == nil { + t.Error("expected _forge_export_meta in output") + } +} + +func TestRunExport_DefaultFilename(t *testing.T) { + dir, cleanup := setupExportTest(t) + defer cleanup() + + cfgFile = filepath.Join(dir, "forge.yaml") + outputDir = "." + exportOutput = "" // Use default + + // Change to temp dir so default filename is written there + origDir, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir: %v", err) + } + defer func() { _ = os.Chdir(origDir) }() + + err := runExport(nil, nil) + if err != nil { + t.Fatalf("runExport() error: %v", err) + } + + // Default filename should be {agent_id}-forge.json + expectedFile := filepath.Join(dir, "test-agent-forge.json") + if _, err := os.Stat(expectedFile); os.IsNotExist(err) { + t.Fatalf("expected default file %s to exist", expectedFile) + } +} + +func TestRunExport_CustomFilename(t *testing.T) { + dir, cleanup := setupExportTest(t) + defer cleanup() + + cfgFile = filepath.Join(dir, "forge.yaml") + outputDir = "." + exportOutput = filepath.Join(dir, "custom-name.json") + + err := runExport(nil, nil) + if err != nil { + t.Fatalf("runExport() error: %v", err) + } + + if _, err := os.Stat(filepath.Join(dir, "custom-name.json")); os.IsNotExist(err) { + t.Fatal("expected custom-name.json to exist") + } +} + +func TestRunExport_PrettyFlag(t *testing.T) { + dir, cleanup := setupExportTest(t) + defer cleanup() + + cfgFile = filepath.Join(dir, "forge.yaml") + outputDir = "." + exportOutput = filepath.Join(dir, "pretty.json") + exportPretty = true + + err := runExport(nil, nil) + if err != nil { + t.Fatalf("runExport() error: %v", err) + } + + data, err := os.ReadFile(filepath.Join(dir, "pretty.json")) + if err != nil { + t.Fatalf("reading output: %v", err) + } + + // Pretty JSON should contain indentation + if !strings.Contains(string(data), " ") { + t.Error("expected indented JSON with --pretty flag") + } +} + +func TestRunExport_IncludeSchemas(t *testing.T) { + dir, cleanup := setupExportTest(t) + defer cleanup() + + // Create tools directory with schema + toolsDir := filepath.Join(dir, ".forge-output", "tools") + if err := os.MkdirAll(toolsDir, 0755); err != nil { + t.Fatalf("creating tools dir: %v", err) + } + schema := `{"type":"object","properties":{"query":{"type":"string"},"limit":{"type":"integer"}}}` + if err := os.WriteFile(filepath.Join(toolsDir, "web-search.schema.json"), []byte(schema), 0644); err != nil { + t.Fatalf("writing tool schema: %v", err) + } + + cfgFile = filepath.Join(dir, "forge.yaml") + outputDir = "." + exportOutput = filepath.Join(dir, "with-schemas.json") + exportIncludeSchemas = true + + err := runExport(nil, nil) + if err != nil { + t.Fatalf("runExport() error: %v", err) + } + + data, err := os.ReadFile(filepath.Join(dir, "with-schemas.json")) + if err != nil { + t.Fatalf("reading output: %v", err) + } + + var result map[string]any + if err := json.Unmarshal(data, &result); err != nil { + t.Fatalf("parsing output: %v", err) + } + + tools, ok := result["tools"].([]any) + if !ok || len(tools) == 0 { + t.Fatal("expected tools array") + } + tool := tools[0].(map[string]any) + inputSchema, ok := tool["input_schema"].(map[string]any) + if !ok { + t.Fatal("expected input_schema to be set from embedded schema") + } + // Should contain the "limit" property from the schema file + props, _ := inputSchema["properties"].(map[string]any) + if props["limit"] == nil { + t.Error("expected embedded schema to include 'limit' property from schema file") + } +} + +func TestRunExport_SimulateImport(t *testing.T) { + dir, cleanup := setupExportTest(t) + defer cleanup() + + cfgFile = filepath.Join(dir, "forge.yaml") + outputDir = "." + exportSimulateImport = true + + // Capture stdout + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := runExport(nil, nil) + + _ = w.Close() + os.Stdout = oldStdout + + if err != nil { + t.Fatalf("runExport() error: %v", err) + } + + buf := make([]byte, 16384) + n, _ := r.Read(buf) + output := string(buf[:n]) + + var simResult map[string]any + if err := json.Unmarshal([]byte(output), &simResult); err != nil { + t.Fatalf("parsing simulate-import output: %v\noutput was: %s", err, output) + } + + def, ok := simResult["agent_definition"].(map[string]any) + if !ok { + t.Fatal("expected agent_definition in output") + } + if def["slug"] != "test-agent" { + t.Errorf("slug = %v, want test-agent", def["slug"]) + } + if def["display_name"] != "Test Agent" { + t.Errorf("display_name = %v, want Test Agent", def["display_name"]) + } +} + +func TestRunExport_ExportMetaFields(t *testing.T) { + dir, cleanup := setupExportTest(t) + defer cleanup() + + cfgFile = filepath.Join(dir, "forge.yaml") + outputDir = "." + exportOutput = filepath.Join(dir, "meta.json") + + err := runExport(nil, nil) + if err != nil { + t.Fatalf("runExport() error: %v", err) + } + + data, err := os.ReadFile(filepath.Join(dir, "meta.json")) + if err != nil { + t.Fatalf("reading output: %v", err) + } + + var result map[string]any + if err := json.Unmarshal(data, &result); err != nil { + t.Fatalf("parsing output: %v", err) + } + + meta, ok := result["_forge_export_meta"].(map[string]any) + if !ok { + t.Fatal("expected _forge_export_meta object") + } + + if meta["exported_at"] == nil { + t.Error("expected exported_at in meta") + } + if meta["forge_cli_version"] == nil { + t.Error("expected forge_cli_version in meta") + } + if meta["compatible_command_versions"] == nil { + t.Error("expected compatible_command_versions in meta") + } +} + +func TestRunExport_RoundTrip(t *testing.T) { + dir, cleanup := setupExportTest(t) + defer cleanup() + + cfgFile = filepath.Join(dir, "forge.yaml") + outputDir = "." + exportOutput = filepath.Join(dir, "roundtrip.json") + + err := runExport(nil, nil) + if err != nil { + t.Fatalf("runExport() error: %v", err) + } + + // Read exported file + data, err := os.ReadFile(filepath.Join(dir, "roundtrip.json")) + if err != nil { + t.Fatalf("reading export: %v", err) + } + + // Strip envelope-only fields (not part of AgentSpec schema) + var envelope map[string]any + if err := json.Unmarshal(data, &envelope); err != nil { + t.Fatalf("parsing export: %v", err) + } + delete(envelope, "_forge_export_meta") + delete(envelope, "security") + delete(envelope, "network_policy") + + // Re-marshal without meta + stripped, err := json.Marshal(envelope) + if err != nil { + t.Fatalf("re-marshalling: %v", err) + } + + // Validate against schema + errs, err := validate.ValidateAgentSpec(stripped) + if err != nil { + t.Fatalf("schema validation error: %v", err) + } + if len(errs) > 0 { + t.Errorf("round-trip validation failed: %v", errs) + } +} + +// setupExportTestWithAgent creates a test directory with a custom agent.json. +func setupExportTestWithAgent(t *testing.T, agentJSON string) (dir string, cleanup func()) { + t.Helper() + dir = t.TempDir() + + writeTestForgeYAML(t, dir, ` +agent_id: test-agent +version: 0.1.0 +framework: custom +entrypoint: python agent.py +model: + provider: openai + name: gpt-4 +tools: + - name: web-search +`) + + outDir := filepath.Join(dir, ".forge-output") + if err := os.MkdirAll(outDir, 0755); err != nil { + t.Fatalf("creating .forge-output: %v", err) + } + + if err := os.WriteFile(filepath.Join(outDir, "agent.json"), []byte(agentJSON), 0644); err != nil { + t.Fatalf("writing agent.json: %v", err) + } + + manifest := `{"built_at":"2025-01-01T00:00:00Z"}` + if err := os.WriteFile(filepath.Join(outDir, "build-manifest.json"), []byte(manifest), 0644); err != nil { + t.Fatalf("writing build-manifest.json: %v", err) + } + + cleanup = func() { + cfgFile = "forge.yaml" + outputDir = "." + exportOutput = "" + exportPretty = false + exportIncludeSchemas = false + exportSimulateImport = false + exportDevMode = false + } + + return dir, cleanup +} + +func TestRunExport_DevToolRejection(t *testing.T) { + agentJSON := makeAgentJSONWithDevTool() + dir, cleanup := setupExportTestWithAgent(t, agentJSON) + defer cleanup() + + cfgFile = filepath.Join(dir, "forge.yaml") + outputDir = "." + exportOutput = filepath.Join(dir, "output.json") + exportDevMode = false + + err := runExport(nil, nil) + if err == nil { + t.Fatal("expected error for dev-category tool without --dev flag") + } + if !strings.Contains(err.Error(), "dev") { + t.Errorf("expected dev-related error, got: %v", err) + } +} + +func TestRunExport_DevToolAllowed(t *testing.T) { + agentJSON := makeAgentJSONWithDevTool() + dir, cleanup := setupExportTestWithAgent(t, agentJSON) + defer cleanup() + + cfgFile = filepath.Join(dir, "forge.yaml") + outputDir = "." + exportOutput = filepath.Join(dir, "output.json") + exportDevMode = true + + err := runExport(nil, nil) + if err != nil { + t.Fatalf("expected no error with --dev flag, got: %v", err) + } + + if _, err := os.Stat(filepath.Join(dir, "output.json")); os.IsNotExist(err) { + t.Fatal("expected output file to exist") + } +} + +func TestRunExport_SecurityBlock(t *testing.T) { + dir, cleanup := setupExportTest(t) + defer cleanup() + + cfgFile = filepath.Join(dir, "forge.yaml") + outputDir = "." + exportOutput = filepath.Join(dir, "security.json") + + err := runExport(nil, nil) + if err != nil { + t.Fatalf("runExport() error: %v", err) + } + + data, err := os.ReadFile(filepath.Join(dir, "security.json")) + if err != nil { + t.Fatalf("reading output: %v", err) + } + + var result map[string]any + if err := json.Unmarshal(data, &result); err != nil { + t.Fatalf("parsing output: %v", err) + } + + security, ok := result["security"].(map[string]any) + if !ok { + t.Fatal("expected security block in envelope") + } + egress, ok := security["egress"].(map[string]any) + if !ok { + t.Fatal("expected egress in security block") + } + if egress["profile"] != "standard" { + t.Errorf("egress.profile = %v, want standard", egress["profile"]) + } + if egress["mode"] != "allowlist" { + t.Errorf("egress.mode = %v, want allowlist", egress["mode"]) + } +} + +func TestRunExport_NetworkPolicyBlock(t *testing.T) { + dir, cleanup := setupExportTest(t) + defer cleanup() + + cfgFile = filepath.Join(dir, "forge.yaml") + outputDir = "." + exportOutput = filepath.Join(dir, "netpol.json") + + err := runExport(nil, nil) + if err != nil { + t.Fatalf("runExport() error: %v", err) + } + + data, err := os.ReadFile(filepath.Join(dir, "netpol.json")) + if err != nil { + t.Fatalf("reading output: %v", err) + } + + var result map[string]any + if err := json.Unmarshal(data, &result); err != nil { + t.Fatalf("parsing output: %v", err) + } + + np, ok := result["network_policy"].(map[string]any) + if !ok { + t.Fatal("expected network_policy block in envelope") + } + if np["default_egress"] != "deny" { + t.Errorf("default_egress = %v, want deny", np["default_egress"]) + } +} + +func TestRunExport_EnrichedMeta(t *testing.T) { + dir, cleanup := setupExportTest(t) + defer cleanup() + + cfgFile = filepath.Join(dir, "forge.yaml") + outputDir = "." + exportOutput = filepath.Join(dir, "enriched.json") + + err := runExport(nil, nil) + if err != nil { + t.Fatalf("runExport() error: %v", err) + } + + data, err := os.ReadFile(filepath.Join(dir, "enriched.json")) + if err != nil { + t.Fatalf("reading output: %v", err) + } + + var result map[string]any + if err := json.Unmarshal(data, &result); err != nil { + t.Fatalf("parsing output: %v", err) + } + + meta, ok := result["_forge_export_meta"].(map[string]any) + if !ok { + t.Fatal("expected _forge_export_meta object") + } + + if meta["tool_categories"] == nil { + t.Error("expected tool_categories in meta") + } + cats, ok := meta["tool_categories"].(map[string]any) + if !ok { + t.Fatal("expected tool_categories to be a map") + } + if cats["builtin"] != float64(1) { + t.Errorf("tool_categories[builtin] = %v, want 1", cats["builtin"]) + } + + if meta["skills_count"] == nil { + t.Error("expected skills_count in meta") + } + if meta["skills_count"] != float64(0) { + t.Errorf("skills_count = %v, want 0", meta["skills_count"]) + } + + if meta["egress_profile"] != "standard" { + t.Errorf("egress_profile = %v, want standard", meta["egress_profile"]) + } +} + +func TestRunExport_RoundTripWithSkills(t *testing.T) { + agentJSON := makeAgentJSONWithSkills() + dir, cleanup := setupExportTestWithAgent(t, agentJSON) + defer cleanup() + + cfgFile = filepath.Join(dir, "forge.yaml") + outputDir = "." + exportOutput = filepath.Join(dir, "skills-roundtrip.json") + + err := runExport(nil, nil) + if err != nil { + t.Fatalf("runExport() error: %v", err) + } + + data, err := os.ReadFile(filepath.Join(dir, "skills-roundtrip.json")) + if err != nil { + t.Fatalf("reading output: %v", err) + } + + // Parse and strip envelope-only fields + var envelope map[string]any + if err := json.Unmarshal(data, &envelope); err != nil { + t.Fatalf("parsing export: %v", err) + } + + // Verify skills are present in a2a + a2a, ok := envelope["a2a"].(map[string]any) + if !ok { + t.Fatal("expected a2a in envelope") + } + skills, ok := a2a["skills"].([]any) + if !ok { + t.Fatal("expected skills array in a2a") + } + if len(skills) != 2 { + t.Errorf("expected 2 skills, got %d", len(skills)) + } + + // Verify meta has skills_count + meta, ok := envelope["_forge_export_meta"].(map[string]any) + if !ok { + t.Fatal("expected _forge_export_meta") + } + if meta["skills_count"] != float64(2) { + t.Errorf("skills_count = %v, want 2", meta["skills_count"]) + } + + // Strip envelope-only fields and validate schema + delete(envelope, "_forge_export_meta") + delete(envelope, "security") + delete(envelope, "network_policy") + + stripped, err := json.Marshal(envelope) + if err != nil { + t.Fatalf("re-marshalling: %v", err) + } + + errs, err := validate.ValidateAgentSpec(stripped) + if err != nil { + t.Fatalf("schema validation error: %v", err) + } + if len(errs) > 0 { + t.Errorf("round-trip validation failed: %v", errs) + } +} + +// makeAgentJSONWithDevTool returns an agent.json with a dev-category tool. +func makeAgentJSONWithDevTool() string { + spec := map[string]any{ + "forge_version": "1.0", + "agent_id": "test-agent", + "version": "0.1.0", + "name": "Test Agent", + "tool_interface_version": "1.0", + "runtime": map[string]any{ + "image": "python:3.11-slim", + "port": 8080, + }, + "tools": []map[string]any{ + { + "name": "debug-tool", + "description": "A dev tool", + "category": "dev", + "input_schema": map[string]any{ + "type": "object", + }, + }, + }, + "model": map[string]any{ + "provider": "openai", + "name": "gpt-4", + }, + "a2a": map[string]any{ + "capabilities": map[string]any{ + "streaming": true, + }, + }, + } + data, _ := json.MarshalIndent(spec, "", " ") + return string(data) +} + +// makeAgentJSONWithSkills returns an agent.json with a2a skills. +func makeAgentJSONWithSkills() string { + spec := map[string]any{ + "forge_version": "1.0", + "agent_id": "test-agent", + "version": "0.1.0", + "name": "Test Agent", + "tool_interface_version": "1.0", + "runtime": map[string]any{ + "image": "python:3.11-slim", + "port": 8080, + }, + "tools": []map[string]any{ + { + "name": "web-search", + "description": "Search the web", + "category": "builtin", + "input_schema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "query": map[string]any{"type": "string"}, + }, + }, + }, + }, + "model": map[string]any{ + "provider": "openai", + "name": "gpt-4", + }, + "a2a": map[string]any{ + "skills": []map[string]any{ + { + "id": "pdf-processing", + "name": "PDF Processing", + "description": "Process PDF documents", + }, + { + "id": "data-analysis", + "name": "Data Analysis", + "description": "Analyze data sets", + }, + }, + "capabilities": map[string]any{ + "streaming": true, + }, + }, + } + data, _ := json.MarshalIndent(spec, "", " ") + return string(data) +} diff --git a/forge-cli/cmd/forge/main.go b/forge-cli/cmd/forge/main.go new file mode 100644 index 0000000..797c6e4 --- /dev/null +++ b/forge-cli/cmd/forge/main.go @@ -0,0 +1,15 @@ +package main + +import ( + forgecmd "github.com/initializ/forge/forge-cli/cmd" +) + +var ( + version = "dev" + commit = "none" +) + +func main() { + forgecmd.SetVersionInfo(version, commit) + forgecmd.Execute() +} diff --git a/forge-cli/cmd/init.go b/forge-cli/cmd/init.go new file mode 100644 index 0000000..cd090e1 --- /dev/null +++ b/forge-cli/cmd/init.go @@ -0,0 +1,947 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "text/template" + + "github.com/spf13/cobra" + + "github.com/initializ/forge/forge-cli/skills" + "github.com/initializ/forge/forge-cli/templates" + skillreg "github.com/initializ/forge/forge-core/registry" + "github.com/initializ/forge/forge-core/tools/builtins" + "github.com/initializ/forge/forge-core/util" +) + +// initOptions holds all the collected options for project scaffolding. +type initOptions struct { + Name string + AgentID string + Framework string + Language string + ModelProvider string + APIKey string // validated provider key + Channels []string + SkillsFile string + Tools []toolEntry + BuiltinTools []string // selected builtin tool names + Skills []string // selected registry skill names + EnvVars map[string]string + NonInteractive bool // skip auto-run in non-interactive mode + Force bool // overwrite existing directory + CustomModel string // custom provider model name +} + +// toolEntry represents a tool parsed from a skills file. +type toolEntry struct { + Name string + Type string +} + +// templateData is passed to all templates during rendering. +type templateData struct { + Name string + AgentID string + Framework string + Language string + Entrypoint string + ModelProvider string + ModelName string + Channels []string + Tools []toolEntry + BuiltinTools []string + SkillEntries []skillTmplData + EgressDomains []string + EnvVars []envVarEntry +} + +// skillTmplData holds template data for a registry skill. +type skillTmplData struct { + Name string + DisplayName string + Description string +} + +// envVarEntry represents an environment variable for templates. +type envVarEntry struct { + Key string + Value string + Comment string +} + +// fileToRender maps a template path to its output destination. +type fileToRender struct { + TemplatePath string + OutputPath string +} + +var initCmd = &cobra.Command{ + Use: "init [name]", + Short: "Initialize a new agent project", + Long: "Scaffold a new AI agent project with the specified framework, language, and model provider.", + Args: cobra.MaximumNArgs(1), + RunE: runInit, +} + +func init() { + initCmd.Flags().StringP("name", "n", "", "agent name") + initCmd.Flags().StringP("framework", "f", "", "framework: crewai, langchain, or custom") + initCmd.Flags().StringP("language", "l", "", "language: python, typescript, or go (custom only)") + initCmd.Flags().StringP("model-provider", "m", "", "model provider: openai, anthropic, gemini, ollama, or custom") + initCmd.Flags().StringSlice("channels", nil, "communication channels (e.g., slack,telegram)") + initCmd.Flags().String("from-skills", "", "path to skills.md file to parse for tools") + initCmd.Flags().Bool("non-interactive", false, "run without interactive prompts (requires all flags)") + initCmd.Flags().StringSlice("tools", nil, "builtin tools to enable (e.g., web_search,http_request)") + initCmd.Flags().StringSlice("skills", nil, "registry skills to include (e.g., github,weather)") + initCmd.Flags().String("api-key", "", "LLM provider API key") + initCmd.Flags().Bool("force", false, "overwrite existing directory") +} + +func runInit(cmd *cobra.Command, args []string) error { + opts := &initOptions{ + EnvVars: make(map[string]string), + } + + // Get name from positional arg or flag + if len(args) > 0 { + opts.Name = args[0] + } + if n, _ := cmd.Flags().GetString("name"); n != "" { + opts.Name = n + } + + // Read flags + opts.Framework, _ = cmd.Flags().GetString("framework") + opts.Language, _ = cmd.Flags().GetString("language") + opts.ModelProvider, _ = cmd.Flags().GetString("model-provider") + opts.Channels, _ = cmd.Flags().GetStringSlice("channels") + opts.SkillsFile, _ = cmd.Flags().GetString("from-skills") + opts.BuiltinTools, _ = cmd.Flags().GetStringSlice("tools") + opts.Skills, _ = cmd.Flags().GetStringSlice("skills") + opts.APIKey, _ = cmd.Flags().GetString("api-key") + + nonInteractive, _ := cmd.Flags().GetBool("non-interactive") + opts.NonInteractive = nonInteractive + opts.Force, _ = cmd.Flags().GetBool("force") + + var err error + if nonInteractive { + err = collectNonInteractive(opts) + } else { + err = collectInteractive(opts) + } + if err != nil { + return err + } + + // Derive agent ID + opts.AgentID = util.Slugify(opts.Name) + + // Parse skills file if provided + if opts.SkillsFile != "" { + tools, parseErr := parseSkillsFile(opts.SkillsFile) + if parseErr != nil { + return fmt.Errorf("parsing skills file: %w", parseErr) + } + opts.Tools = tools + } + + return scaffold(opts) +} + +func collectInteractive(opts *initOptions) error { + var err error + + // ── Step 1: Name ── + if opts.Name == "" { + opts.Name, err = askText("Agent name", "my-agent") + if err != nil { + return err + } + } + + // Default framework and language (no interactive prompt per rework spec) + if opts.Framework == "" { + opts.Framework = "custom" + } + if opts.Language == "" { + opts.Language = "python" + } + + // ── Step 2: Provider + API Key Validation ── + if opts.ModelProvider == "" { + _, opts.ModelProvider, err = askSelect("Model provider", []string{"openai", "anthropic", "gemini", "ollama", "custom"}) + if err != nil { + return err + } + } + + if opts.APIKey == "" && (opts.ModelProvider == "openai" || opts.ModelProvider == "anthropic" || opts.ModelProvider == "gemini") { + for { + opts.APIKey, err = askPassword(fmt.Sprintf("%s API key", titleCase(opts.ModelProvider))) + if err != nil { + return err + } + if opts.APIKey == "" { + fmt.Println(" Skipping API key validation.") + break + } + + fmt.Print(" Validating API key... ") + if valErr := validateProviderKey(opts.ModelProvider, opts.APIKey); valErr != nil { + fmt.Printf("FAILED: %s\n", valErr) + retry, _ := askConfirm("Retry with a different key?") + if !retry { + fmt.Println(" Continuing without validation.") + break + } + continue + } + fmt.Println("OK") + break + } + } + + if opts.ModelProvider == "ollama" { + fmt.Print(" Checking Ollama connectivity... ") + if valErr := validateProviderKey("ollama", ""); valErr != nil { + fmt.Printf("WARNING: %s\n", valErr) + } else { + fmt.Println("OK") + } + } + + if opts.ModelProvider == "custom" { + baseURL, urlErr := askText("Base URL (e.g. http://localhost:11434/v1)", "") + if urlErr != nil { + return urlErr + } + if baseURL != "" { + opts.EnvVars["MODEL_BASE_URL"] = baseURL + } + modelName, modErr := askText("Model name", "default") + if modErr != nil { + return modErr + } + opts.CustomModel = modelName + + needsAuth, _ := askConfirm("Does this endpoint require an auth header?") + if needsAuth { + key, keyErr := askPassword("API key or auth token") + if keyErr != nil { + return keyErr + } + if key != "" { + opts.EnvVars["MODEL_API_KEY"] = key + } + } + } + + // Store provider API key + storeProviderEnvVar(opts) + + // ── Step 3: Channel Connector (optional) ── + if len(opts.Channels) == 0 { + _, channel, chErr := askSelect("Channel connector", []string{ + "none — CLI / API only", + "telegram — easy setup, no public URL needed", + "slack — Socket Mode, no public URL needed", + }) + if chErr != nil { + return chErr + } + channelName := strings.SplitN(channel, " — ", 2)[0] + if channelName != "none" { + opts.Channels = []string{channelName} + } + + // Collect channel tokens + if channelName == "telegram" { + fmt.Println("\n Telegram Bot Setup:") + fmt.Println(" 1. Open Telegram, message @BotFather") + fmt.Println(" 2. Send /newbot and follow prompts") + fmt.Println(" 3. Copy the bot token") + token, tokErr := askPassword("Telegram Bot Token") + if tokErr != nil { + return tokErr + } + if token != "" { + opts.EnvVars["TELEGRAM_BOT_TOKEN"] = token + } + } + if channelName == "slack" { + fmt.Println("\n Slack Socket Mode Setup:") + fmt.Println(" 1. Create a Slack App at https://api.slack.com/apps") + fmt.Println(" 2. Enable Socket Mode, generate app-level token") + fmt.Println(" 3. Add bot scopes: chat:write, app_mentions:read") + appToken, appErr := askPassword("Slack App Token (xapp-...)") + if appErr != nil { + return appErr + } + botToken, botErr := askPassword("Slack Bot Token (xoxb-...)") + if botErr != nil { + return botErr + } + if appToken != "" { + opts.EnvVars["SLACK_APP_TOKEN"] = appToken + } + if botToken != "" { + opts.EnvVars["SLACK_BOT_TOKEN"] = botToken + } + } + } + + // ── Step 4: Builtin Tools ── + if len(opts.BuiltinTools) == 0 { + allTools := builtins.All() + var toolDescriptions []string + for _, t := range allTools { + toolDescriptions = append(toolDescriptions, fmt.Sprintf("%s — %s", t.Name(), t.Description())) + } + fmt.Println("\nBuiltin tools:") + selectedDescs, err := askMultiSelect("Builtin tools", toolDescriptions) + if err != nil { + return err + } + // Extract tool names from "name — description" format + for _, desc := range selectedDescs { + name := strings.SplitN(desc, " — ", 2)[0] + opts.BuiltinTools = append(opts.BuiltinTools, name) + } + } + + // If web_search selected, check for Perplexity key + if containsStr(opts.BuiltinTools, "web_search") && os.Getenv("PERPLEXITY_API_KEY") == "" { + if _, exists := opts.EnvVars["PERPLEXITY_API_KEY"]; !exists { + key, err := askPassword("Perplexity API key for web_search") + if err != nil { + return err + } + if key != "" { + fmt.Print(" Validating Perplexity key... ") + if valErr := validatePerplexityKey(key); valErr != nil { + fmt.Printf("FAILED: %s\n", valErr) + fmt.Println(" Key saved anyway — you can fix it later in .env") + } else { + fmt.Println("OK") + } + opts.EnvVars["PERPLEXITY_API_KEY"] = key + } + } + } + + // ── Step 6: External Skills ── + if len(opts.Skills) == 0 { + regSkills, err := skillreg.LoadIndex() + if err != nil { + fmt.Printf(" Warning: could not load skill registry: %s\n", err) + } else if len(regSkills) > 0 { + var skillDescriptions []string + for _, s := range regSkills { + desc := fmt.Sprintf("%s — %s", s.Name, s.Description) + if len(s.RequiredEnv) > 0 { + desc += fmt.Sprintf(" (requires: %s)", strings.Join(s.RequiredEnv, ", ")) + } + if len(s.RequiredBins) > 0 { + desc += fmt.Sprintf(" (bins: %s)", strings.Join(s.RequiredBins, ", ")) + } + skillDescriptions = append(skillDescriptions, desc) + } + fmt.Println("\nExternal skills (from registry):") + selectedDescs, err := askMultiSelect("External skills", skillDescriptions) + if err != nil { + return err + } + for _, desc := range selectedDescs { + name := strings.SplitN(desc, " — ", 2)[0] + opts.Skills = append(opts.Skills, name) + } + } + } + + // Check requirements for selected skills + checkSkillRequirements(opts) + + // ── Step 7: Egress Review ── + selectedSkillInfos := lookupSelectedSkills(opts.Skills) + egressDomains := deriveEgressDomains(opts, selectedSkillInfos) + + if len(egressDomains) > 0 { + fmt.Println("\nComputed egress domains:") + for _, d := range egressDomains { + fmt.Printf(" - %s\n", d) + } + accepted, _ := askConfirm("Accept egress domains?") + if !accepted { + customDomains, err := askText("Additional domains (comma-separated, or empty)", "") + if err != nil { + return err + } + if customDomains != "" { + for _, d := range strings.Split(customDomains, ",") { + d = strings.TrimSpace(d) + if d != "" { + egressDomains = append(egressDomains, d) + } + } + } + } + } + + // Store computed egress domains for scaffold + opts.EnvVars["__egress_domains"] = strings.Join(egressDomains, ",") + + // ── Step 8: Review + Generate ── + fmt.Println("\n=== Project Summary ===") + fmt.Printf(" Name: %s\n", opts.Name) + fmt.Printf(" Provider: %s\n", opts.ModelProvider) + if len(opts.Channels) > 0 { + fmt.Printf(" Channels: %s\n", strings.Join(opts.Channels, ", ")) + } + if len(opts.BuiltinTools) > 0 { + fmt.Printf(" Builtin tools: %s\n", strings.Join(opts.BuiltinTools, ", ")) + } + if len(opts.Skills) > 0 { + fmt.Printf(" Skills: %s\n", strings.Join(opts.Skills, ", ")) + } + if len(egressDomains) > 0 { + fmt.Printf(" Egress: %d domains\n", len(egressDomains)) + } + + confirmed, _ := askConfirm("Create Agent?") + if !confirmed { + return fmt.Errorf("agent creation cancelled") + } + + return nil +} + +func collectNonInteractive(opts *initOptions) error { + if opts.Name == "" { + return fmt.Errorf("--name is required in non-interactive mode") + } + if opts.ModelProvider == "" { + return fmt.Errorf("--model-provider is required in non-interactive mode") + } + + // Default framework and language if not provided + if opts.Framework == "" { + opts.Framework = "custom" + } + if opts.Language == "" { + opts.Language = "python" + } + + // Validate framework + switch opts.Framework { + case "crewai", "langchain", "custom": + default: + return fmt.Errorf("invalid framework %q: must be crewai, langchain, or custom", opts.Framework) + } + + // Validate language + switch opts.Framework { + case "crewai", "langchain": + if opts.Language != "python" { + return fmt.Errorf("framework %q only supports python", opts.Framework) + } + case "custom": + switch opts.Language { + case "python", "typescript", "go": + default: + return fmt.Errorf("invalid language %q: must be python, typescript, or go", opts.Language) + } + } + + // Validate model provider + switch opts.ModelProvider { + case "openai", "anthropic", "gemini", "ollama", "custom": + default: + return fmt.Errorf("invalid model-provider %q: must be openai, anthropic, gemini, ollama, or custom", opts.ModelProvider) + } + + // Validate API key if provided + if opts.APIKey != "" { + if err := validateProviderKey(opts.ModelProvider, opts.APIKey); err != nil { + fmt.Printf("Warning: API key validation failed: %s\n", err) + } + } + + // Store provider env var + storeProviderEnvVar(opts) + + // Validate builtin tool names + if len(opts.BuiltinTools) > 0 { + allTools := builtins.All() + validNames := make(map[string]bool) + for _, t := range allTools { + validNames[t.Name()] = true + } + for _, name := range opts.BuiltinTools { + if !validNames[name] { + fmt.Printf("Warning: unknown builtin tool %q\n", name) + } + } + } + + // Validate skill names and check requirements + if len(opts.Skills) > 0 { + regSkills, err := skillreg.LoadIndex() + if err != nil { + fmt.Printf("Warning: could not load skill registry: %s\n", err) + } else { + validNames := make(map[string]bool) + for _, s := range regSkills { + validNames[s.Name] = true + } + for _, name := range opts.Skills { + if !validNames[name] { + fmt.Printf("Warning: unknown skill %q\n", name) + } + } + } + checkSkillRequirements(opts) + } + + return nil +} + +// storeProviderEnvVar stores the appropriate environment variable for the selected provider. +func storeProviderEnvVar(opts *initOptions) { + if opts.APIKey == "" { + return + } + switch opts.ModelProvider { + case "openai": + opts.EnvVars["OPENAI_API_KEY"] = opts.APIKey + case "anthropic": + opts.EnvVars["ANTHROPIC_API_KEY"] = opts.APIKey + case "gemini": + opts.EnvVars["GEMINI_API_KEY"] = opts.APIKey + } +} + +// checkSkillRequirements checks binary and env requirements for selected skills. +func checkSkillRequirements(opts *initOptions) { + for _, skillName := range opts.Skills { + info := skillreg.GetSkillByName(skillName) + if info == nil { + continue + } + + // Check required binaries + for _, bin := range info.RequiredBins { + if _, err := exec.LookPath(bin); err != nil { + fmt.Printf(" Warning: skill %q requires %q binary (not found in PATH)\n", skillName, bin) + } + } + + // Check required env vars + for _, env := range info.RequiredEnv { + if os.Getenv(env) == "" { + if _, exists := opts.EnvVars[env]; !exists { + fmt.Printf(" Note: skill %q requires %s (will be added to .env)\n", skillName, env) + opts.EnvVars[env] = "" + } + } + } + + // Check one-of env vars + if len(info.OneOfEnv) > 0 { + found := false + for _, env := range info.OneOfEnv { + if os.Getenv(env) != "" { + found = true + break + } + if v, exists := opts.EnvVars[env]; exists && v != "" { + found = true + break + } + } + if !found { + fmt.Printf(" Note: skill %q requires one of: %s (will be added to .env)\n", + skillName, strings.Join(info.OneOfEnv, ", ")) + opts.EnvVars[info.OneOfEnv[0]] = "" + } + } + } +} + +// lookupSelectedSkills returns SkillInfo entries for the selected skill names. +func lookupSelectedSkills(skillNames []string) []skillreg.SkillInfo { + var result []skillreg.SkillInfo + for _, name := range skillNames { + info := skillreg.GetSkillByName(name) + if info != nil { + result = append(result, *info) + } + } + return result +} + +func parseSkillsFile(path string) ([]toolEntry, error) { + entries, err := skills.ParseFile(path) + if err != nil { + return nil, err + } + var tools []toolEntry + for _, e := range entries { + tools = append(tools, toolEntry{Name: e.Name, Type: "custom"}) + } + return tools, nil +} + +func scaffold(opts *initOptions) error { + dir := filepath.Join(".", opts.AgentID) + + // Check if directory already exists + if !opts.Force { + if _, err := os.Stat(dir); err == nil { + return fmt.Errorf("directory %q already exists (use --force to overwrite)", dir) + } + } + + // Create project directories + for _, subDir := range []string{"tools", "skills"} { + if err := os.MkdirAll(filepath.Join(dir, subDir), 0o755); err != nil { + return fmt.Errorf("creating directory %s: %w", subDir, err) + } + } + + data := buildTemplateData(opts) + manifest := getFileManifest(opts) + + for _, f := range manifest { + tmplContent, err := templates.GetInitTemplate(f.TemplatePath) + if err != nil { + return fmt.Errorf("reading template %s: %w", f.TemplatePath, err) + } + + tmpl, err := template.New(f.TemplatePath).Parse(tmplContent) + if err != nil { + return fmt.Errorf("parsing template %s: %w", f.TemplatePath, err) + } + + outPath := filepath.Join(dir, f.OutputPath) + + // Ensure parent directory exists + if parentDir := filepath.Dir(outPath); parentDir != dir { + if err := os.MkdirAll(parentDir, 0o755); err != nil { + return fmt.Errorf("creating directory for %s: %w", f.OutputPath, err) + } + } + + out, err := os.Create(outPath) + if err != nil { + return fmt.Errorf("creating file %s: %w", f.OutputPath, err) + } + + if err := tmpl.Execute(out, data); err != nil { + _ = out.Close() + return fmt.Errorf("rendering template %s: %w", f.TemplatePath, err) + } + _ = out.Close() + } + + // Write .env file with collected env vars + if err := writeEnvFile(dir, data.EnvVars); err != nil { + return fmt.Errorf("writing .env file: %w", err) + } + + // Vendor selected registry skills + for _, skillName := range opts.Skills { + content, err := skillreg.LoadSkillFile(skillName) + if err != nil { + fmt.Printf("Warning: could not load skill file for %q: %s\n", skillName, err) + continue + } + skillPath := filepath.Join(dir, "skills", skillName+".md") + if err := os.WriteFile(skillPath, content, 0o644); err != nil { + return fmt.Errorf("writing skill file %s: %w", skillName, err) + } + } + + fmt.Printf("\nCreated agent project in ./%s\n", opts.AgentID) + + // In non-interactive mode, just print the command + if opts.NonInteractive { + fmt.Printf(" cd %s && forge run\n", opts.AgentID) + return nil + } + + // Auto-run the agent + if err := os.Chdir(dir); err != nil { + return fmt.Errorf("changing to project dir: %w", err) + } + + args := []string{"run"} + if len(opts.Channels) > 0 { + args = append(args, "--with", strings.Join(opts.Channels, ",")) + } + + forgeBin, err := os.Executable() + if err != nil { + forgeBin = "forge" + } + runCmd := exec.Command(forgeBin, args...) + runCmd.Stdin = os.Stdin + runCmd.Stdout = os.Stdout + runCmd.Stderr = os.Stderr + return runCmd.Run() +} + +// writeEnvFile creates a .env file with the collected environment variables. +func writeEnvFile(dir string, vars []envVarEntry) error { + if len(vars) == 0 { + return nil + } + + envPath := filepath.Join(dir, ".env") + f, err := os.Create(envPath) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + + for _, v := range vars { + if v.Comment != "" { + _, _ = fmt.Fprintf(f, "# %s\n", v.Comment) + } + _, _ = fmt.Fprintf(f, "%s=%s\n", v.Key, v.Value) + } + return nil +} + +func getFileManifest(opts *initOptions) []fileToRender { + files := []fileToRender{ + {TemplatePath: "forge.yaml.tmpl", OutputPath: "forge.yaml"}, + {TemplatePath: "skills.md.tmpl", OutputPath: "skills.md"}, + {TemplatePath: "env.example.tmpl", OutputPath: ".env.example"}, + {TemplatePath: "gitignore.tmpl", OutputPath: ".gitignore"}, + } + + switch opts.Framework { + case "crewai": + files = append(files, + fileToRender{TemplatePath: "crewai/agent.py.tmpl", OutputPath: "agent.py"}, + fileToRender{TemplatePath: "crewai/example_tool.py.tmpl", OutputPath: "tools/example_tool.py"}, + ) + case "langchain": + files = append(files, + fileToRender{TemplatePath: "langchain/agent.py.tmpl", OutputPath: "agent.py"}, + fileToRender{TemplatePath: "langchain/example_tool.py.tmpl", OutputPath: "tools/example_tool.py"}, + ) + case "custom": + switch opts.Language { + case "python": + files = append(files, + fileToRender{TemplatePath: "custom/agent.py.tmpl", OutputPath: "agent.py"}, + fileToRender{TemplatePath: "custom/example_tool.py.tmpl", OutputPath: "tools/example_tool.py"}, + ) + case "typescript": + files = append(files, + fileToRender{TemplatePath: "custom/agent.ts.tmpl", OutputPath: "agent.ts"}, + fileToRender{TemplatePath: "custom/example_tool.ts.tmpl", OutputPath: "tools/example_tool.ts"}, + ) + case "go": + files = append(files, + fileToRender{TemplatePath: "custom/main.go.tmpl", OutputPath: "main.go"}, + fileToRender{TemplatePath: "custom/example_tool.go.tmpl", OutputPath: "tools/example_tool.go"}, + ) + } + } + + // Channel config files + for _, ch := range opts.Channels { + files = append(files, fileToRender{ + TemplatePath: ch + "-config.yaml.tmpl", + OutputPath: ch + "-config.yaml", + }) + } + + return files +} + +func buildTemplateData(opts *initOptions) templateData { + data := templateData{ + Name: opts.Name, + AgentID: opts.AgentID, + Framework: opts.Framework, + Language: opts.Language, + ModelProvider: opts.ModelProvider, + Channels: opts.Channels, + Tools: opts.Tools, + BuiltinTools: opts.BuiltinTools, + } + + // Set entrypoint based on framework/language + switch opts.Framework { + case "crewai", "langchain": + data.Entrypoint = "python agent.py" + case "custom": + switch opts.Language { + case "python": + data.Entrypoint = "python agent.py" + case "typescript": + data.Entrypoint = "bun run agent.ts" + case "go": + data.Entrypoint = "go run main.go" + } + } + + // Set default model name based on provider + switch opts.ModelProvider { + case "openai": + data.ModelName = "gpt-4o-mini" + case "anthropic": + data.ModelName = "claude-sonnet-4-20250514" + case "gemini": + data.ModelName = "gemini-2.5-flash" + case "ollama": + data.ModelName = "llama3" + default: + if opts.CustomModel != "" { + data.ModelName = opts.CustomModel + } else { + data.ModelName = "default" + } + } + + // Build skill entries for templates + for _, skillName := range opts.Skills { + info := skillreg.GetSkillByName(skillName) + if info != nil { + data.SkillEntries = append(data.SkillEntries, skillTmplData{ + Name: info.Name, + DisplayName: info.DisplayName, + Description: info.Description, + }) + } + } + + // Compute egress domains + selectedSkillInfos := lookupSelectedSkills(opts.Skills) + data.EgressDomains = deriveEgressDomains(opts, selectedSkillInfos) + + // Check if egress domains were overridden in interactive mode + if stored, ok := opts.EnvVars["__egress_domains"]; ok && stored != "" { + data.EgressDomains = strings.Split(stored, ",") + } + + // Build env vars + data.EnvVars = buildEnvVars(opts) + + return data +} + +// buildEnvVars builds the list of environment variables for the .env file. +func buildEnvVars(opts *initOptions) []envVarEntry { + var vars []envVarEntry + + // Provider key + switch opts.ModelProvider { + case "openai": + val := opts.EnvVars["OPENAI_API_KEY"] + if val == "" { + val = "your-api-key-here" + } + vars = append(vars, envVarEntry{Key: "OPENAI_API_KEY", Value: val, Comment: "OpenAI API key"}) + case "anthropic": + val := opts.EnvVars["ANTHROPIC_API_KEY"] + if val == "" { + val = "your-api-key-here" + } + vars = append(vars, envVarEntry{Key: "ANTHROPIC_API_KEY", Value: val, Comment: "Anthropic API key"}) + case "gemini": + val := opts.EnvVars["GEMINI_API_KEY"] + if val == "" { + val = "your-api-key-here" + } + vars = append(vars, envVarEntry{Key: "GEMINI_API_KEY", Value: val, Comment: "Gemini API key"}) + case "ollama": + vars = append(vars, envVarEntry{Key: "OLLAMA_HOST", Value: "http://localhost:11434", Comment: "Ollama host"}) + case "custom": + baseURL := opts.EnvVars["MODEL_BASE_URL"] + if baseURL != "" { + vars = append(vars, envVarEntry{Key: "MODEL_BASE_URL", Value: baseURL, Comment: "Custom model endpoint URL"}) + } + apiKeyVal := opts.EnvVars["MODEL_API_KEY"] + if apiKeyVal == "" { + apiKeyVal = "your-api-key-here" + } + vars = append(vars, envVarEntry{Key: "MODEL_API_KEY", Value: apiKeyVal, Comment: "Model provider API key"}) + } + + // Perplexity key if web_search selected + if containsStr(opts.BuiltinTools, "web_search") { + val := opts.EnvVars["PERPLEXITY_API_KEY"] + if val == "" { + val = "your-perplexity-key-here" + } + vars = append(vars, envVarEntry{Key: "PERPLEXITY_API_KEY", Value: val, Comment: "Perplexity API key for web_search"}) + } + + // Channel env vars + for _, ch := range opts.Channels { + switch ch { + case "telegram": + val := opts.EnvVars["TELEGRAM_BOT_TOKEN"] + vars = append(vars, envVarEntry{Key: "TELEGRAM_BOT_TOKEN", Value: val, Comment: "Telegram bot token"}) + case "slack": + appVal := opts.EnvVars["SLACK_APP_TOKEN"] + vars = append(vars, envVarEntry{Key: "SLACK_APP_TOKEN", Value: appVal, Comment: "Slack app-level token (xapp-...)"}) + botVal := opts.EnvVars["SLACK_BOT_TOKEN"] + vars = append(vars, envVarEntry{Key: "SLACK_BOT_TOKEN", Value: botVal, Comment: "Slack bot token (xoxb-...)"}) + } + } + + // Skill env vars + for _, skillName := range opts.Skills { + info := skillreg.GetSkillByName(skillName) + if info == nil { + continue + } + for _, env := range info.RequiredEnv { + val := opts.EnvVars[env] + if val == "" { + val = "" + } + vars = append(vars, envVarEntry{Key: env, Value: val, Comment: fmt.Sprintf("Required by %s skill", skillName)}) + } + if len(info.OneOfEnv) > 0 { + for _, env := range info.OneOfEnv { + val := opts.EnvVars[env] + vars = append(vars, envVarEntry{ + Key: env, + Value: val, + Comment: fmt.Sprintf("One of required by %s skill", skillName), + }) + } + } + } + + return vars +} + +// containsStr checks if a string slice contains the given value. +func containsStr(slice []string, val string) bool { + for _, s := range slice { + if s == val { + return true + } + } + return false +} + +// titleCase capitalizes the first letter of a string. +func titleCase(s string) string { + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} diff --git a/forge-cli/cmd/init_egress.go b/forge-cli/cmd/init_egress.go new file mode 100644 index 0000000..6497a6c --- /dev/null +++ b/forge-cli/cmd/init_egress.go @@ -0,0 +1,55 @@ +package cmd + +import ( + "sort" + + skillreg "github.com/initializ/forge/forge-core/registry" + "github.com/initializ/forge/forge-core/security" +) + +// providerDomains maps model provider names to their API domains. +var providerDomains = map[string]string{ + "openai": "api.openai.com", + "anthropic": "api.anthropic.com", + "gemini": "generativelanguage.googleapis.com", + // ollama is local, no egress needed +} + +// deriveEgressDomains computes the full set of egress domains needed based on +// the provider, channels, builtin tools, and selected registry skills. +func deriveEgressDomains(opts *initOptions, skills []skillreg.SkillInfo) []string { + seen := make(map[string]bool) + var domains []string + + add := func(d string) { + if d != "" && !seen[d] { + seen[d] = true + domains = append(domains, d) + } + } + + // 1. Provider domain + if d, ok := providerDomains[opts.ModelProvider]; ok { + add(d) + } + + // 2. Channel domains + for _, d := range security.ResolveCapabilities(opts.Channels) { + add(d) + } + + // 3. Tool domains + for _, d := range security.InferToolDomains(opts.BuiltinTools) { + add(d) + } + + // 4. Skill domains + for _, s := range skills { + for _, d := range s.EgressDomains { + add(d) + } + } + + sort.Strings(domains) + return domains +} diff --git a/forge-cli/cmd/init_prompt.go b/forge-cli/cmd/init_prompt.go new file mode 100644 index 0000000..3bd501d --- /dev/null +++ b/forge-cli/cmd/init_prompt.go @@ -0,0 +1,80 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/manifoldco/promptui" +) + +// askText prompts the user for a text value with an optional default. +func askText(label, defaultVal string) (string, error) { + p := promptui.Prompt{ + Label: label, + Default: defaultVal, + } + result, err := p.Run() + if err != nil { + return "", fmt.Errorf("prompt %q failed: %w", label, err) + } + return result, nil +} + +// askSelect presents a list of items and returns the selected index and value. +func askSelect(label string, items []string) (int, string, error) { + s := promptui.Select{ + Label: label, + Items: items, + } + idx, val, err := s.Run() + if err != nil { + return -1, "", fmt.Errorf("prompt %q failed: %w", label, err) + } + return idx, val, nil +} + +// askMultiSelect lets the user confirm each item individually, returning selected items. +func askMultiSelect(label string, items []string) ([]string, error) { + fmt.Printf("%s (confirm each):\n", label) + var selected []string + for _, item := range items { + p := promptui.Prompt{ + Label: fmt.Sprintf(" Include %s", item), + IsConfirm: true, + } + if _, err := p.Run(); err == nil { + selected = append(selected, item) + } + } + return selected, nil +} + +// askConfirm asks for a yes/no confirmation. +func askConfirm(label string) (bool, error) { + p := promptui.Prompt{ + Label: label, + IsConfirm: true, + } + _, err := p.Run() + if err != nil { + // promptui returns an error for "No" — distinguish from real errors + if strings.Contains(err.Error(), "^C") || err == promptui.ErrAbort { + return false, fmt.Errorf("prompt aborted") + } + return false, nil + } + return true, nil +} + +// askPassword prompts for a secret value with character masking. +func askPassword(label string) (string, error) { + p := promptui.Prompt{ + Label: label, + Mask: '*', + } + result, err := p.Run() + if err != nil { + return "", fmt.Errorf("prompt %q failed: %w", label, err) + } + return result, nil +} diff --git a/forge-cli/cmd/init_test.go b/forge-cli/cmd/init_test.go new file mode 100644 index 0000000..85a7f9c --- /dev/null +++ b/forge-cli/cmd/init_test.go @@ -0,0 +1,725 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/initializ/forge/forge-cli/config" +) + +func TestParseSkillsFileHeadings(t *testing.T) { + content := `# My Agent Skills + +## Tool: web_search + +A tool for searching the web. + +## Tool: sql_query + +A tool for running SQL queries. +` + path := writeTempFile(t, "skills.md", content) + tools, err := parseSkillsFile(path) + if err != nil { + t.Fatalf("parseSkillsFile error: %v", err) + } + if len(tools) != 2 { + t.Fatalf("expected 2 tools, got %d", len(tools)) + } + if tools[0].Name != "web_search" { + t.Errorf("expected tool[0].Name = web_search, got %q", tools[0].Name) + } + if tools[1].Name != "sql_query" { + t.Errorf("expected tool[1].Name = sql_query, got %q", tools[1].Name) + } +} + +func TestParseSkillsFileListItems(t *testing.T) { + content := `# Tools + +- calculator +- translator +` + path := writeTempFile(t, "skills.md", content) + tools, err := parseSkillsFile(path) + if err != nil { + t.Fatalf("parseSkillsFile error: %v", err) + } + if len(tools) != 2 { + t.Fatalf("expected 2 tools, got %d", len(tools)) + } + if tools[0].Name != "calculator" { + t.Errorf("expected tool[0].Name = calculator, got %q", tools[0].Name) + } + if tools[1].Name != "translator" { + t.Errorf("expected tool[1].Name = translator, got %q", tools[1].Name) + } +} + +func TestParseSkillsFileMixed(t *testing.T) { + content := `# Skills + +## Tool: api_client + +Calls APIs. + +# Other + +- helper_util +` + path := writeTempFile(t, "skills.md", content) + tools, err := parseSkillsFile(path) + if err != nil { + t.Fatalf("parseSkillsFile error: %v", err) + } + if len(tools) != 2 { + t.Fatalf("expected 2 tools, got %d", len(tools)) + } + if tools[0].Name != "api_client" { + t.Errorf("expected tool[0].Name = api_client, got %q", tools[0].Name) + } + if tools[1].Name != "helper_util" { + t.Errorf("expected tool[1].Name = helper_util, got %q", tools[1].Name) + } +} + +func TestCollectNonInteractiveMissingName(t *testing.T) { + opts := &initOptions{Framework: "custom", ModelProvider: "openai", EnvVars: map[string]string{}} + err := collectNonInteractive(opts) + if err == nil { + t.Fatal("expected error for missing name") + } +} + +func TestCollectNonInteractiveFrameworkDefaults(t *testing.T) { + opts := &initOptions{Name: "test", ModelProvider: "openai", EnvVars: map[string]string{}} + err := collectNonInteractive(opts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts.Framework != "custom" { + t.Errorf("expected framework custom, got %q", opts.Framework) + } + if opts.Language != "python" { + t.Errorf("expected language python, got %q", opts.Language) + } +} + +func TestCollectNonInteractiveMissingProvider(t *testing.T) { + opts := &initOptions{Name: "test", EnvVars: map[string]string{}} + err := collectNonInteractive(opts) + if err == nil { + t.Fatal("expected error for missing model-provider") + } +} + +func TestCollectNonInteractiveInvalidFramework(t *testing.T) { + opts := &initOptions{Name: "test", Framework: "invalid", ModelProvider: "openai", EnvVars: map[string]string{}} + err := collectNonInteractive(opts) + if err == nil { + t.Fatal("expected error for invalid framework") + } +} + +func TestCollectNonInteractiveCrewAIGoLanguage(t *testing.T) { + opts := &initOptions{Name: "test", Framework: "crewai", Language: "go", ModelProvider: "openai", EnvVars: map[string]string{}} + err := collectNonInteractive(opts) + if err == nil { + t.Fatal("expected error for crewai with go language") + } +} + +func TestCollectNonInteractiveLangchainTypeScript(t *testing.T) { + opts := &initOptions{Name: "test", Framework: "langchain", Language: "typescript", ModelProvider: "openai", EnvVars: map[string]string{}} + err := collectNonInteractive(opts) + if err == nil { + t.Fatal("expected error for langchain with typescript language") + } +} + +func TestCollectNonInteractiveCustomDefaults(t *testing.T) { + opts := &initOptions{Name: "test", ModelProvider: "openai", EnvVars: map[string]string{}} + err := collectNonInteractive(opts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts.Framework != "custom" { + t.Errorf("expected default framework custom, got %q", opts.Framework) + } + if opts.Language != "python" { + t.Errorf("expected default language python, got %q", opts.Language) + } +} + +func TestCollectNonInteractive_WithTools(t *testing.T) { + opts := &initOptions{ + Name: "test", + ModelProvider: "openai", + BuiltinTools: []string{"web_search", "http_request"}, + EnvVars: map[string]string{}, + } + err := collectNonInteractive(opts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(opts.BuiltinTools) != 2 { + t.Errorf("expected 2 builtin tools, got %d", len(opts.BuiltinTools)) + } +} + +func TestCollectNonInteractive_WithSkills(t *testing.T) { + opts := &initOptions{ + Name: "test", + ModelProvider: "openai", + Skills: []string{"github", "weather"}, + EnvVars: map[string]string{}, + } + err := collectNonInteractive(opts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(opts.Skills) != 2 { + t.Errorf("expected 2 skills, got %d", len(opts.Skills)) + } +} + +func TestCollectNonInteractive_RequiresName(t *testing.T) { + opts := &initOptions{ + Framework: "custom", + ModelProvider: "openai", + EnvVars: map[string]string{}, + } + err := collectNonInteractive(opts) + if err == nil { + t.Fatal("expected error for missing name") + } + if !strings.Contains(err.Error(), "--name") { + t.Errorf("expected error about --name, got: %v", err) + } +} + +func TestGetFileManifestCrewAI(t *testing.T) { + opts := &initOptions{Framework: "crewai", Language: "python"} + files := getFileManifest(opts) + assertContainsTemplate(t, files, "crewai/agent.py.tmpl") + assertContainsTemplate(t, files, "crewai/example_tool.py.tmpl") +} + +func TestGetFileManifestLangchain(t *testing.T) { + opts := &initOptions{Framework: "langchain", Language: "python"} + files := getFileManifest(opts) + assertContainsTemplate(t, files, "langchain/agent.py.tmpl") + assertContainsTemplate(t, files, "langchain/example_tool.py.tmpl") +} + +func TestGetFileManifestCustomPython(t *testing.T) { + opts := &initOptions{Framework: "custom", Language: "python"} + files := getFileManifest(opts) + assertContainsTemplate(t, files, "custom/agent.py.tmpl") + assertContainsTemplate(t, files, "custom/example_tool.py.tmpl") +} + +func TestGetFileManifestCustomTypeScript(t *testing.T) { + opts := &initOptions{Framework: "custom", Language: "typescript"} + files := getFileManifest(opts) + assertContainsTemplate(t, files, "custom/agent.ts.tmpl") + assertContainsTemplate(t, files, "custom/example_tool.ts.tmpl") +} + +func TestGetFileManifestCustomGo(t *testing.T) { + opts := &initOptions{Framework: "custom", Language: "go"} + files := getFileManifest(opts) + assertContainsTemplate(t, files, "custom/main.go.tmpl") + assertContainsTemplate(t, files, "custom/example_tool.go.tmpl") +} + +func TestGetFileManifestCommonFiles(t *testing.T) { + opts := &initOptions{Framework: "custom", Language: "python"} + files := getFileManifest(opts) + assertContainsTemplate(t, files, "forge.yaml.tmpl") + assertContainsTemplate(t, files, "skills.md.tmpl") + assertContainsTemplate(t, files, "env.example.tmpl") + assertContainsTemplate(t, files, "gitignore.tmpl") +} + +func TestScaffoldIntegration(t *testing.T) { + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("chdir: %v", err) + } + defer func() { _ = os.Chdir(origDir) }() + + opts := &initOptions{ + Name: "Test Agent", + AgentID: "test-agent", + Framework: "custom", + Language: "go", + ModelProvider: "openai", + Channels: []string{"slack"}, + Tools: []toolEntry{ + {Name: "web_search", Type: "custom"}, + }, + EnvVars: map[string]string{}, + NonInteractive: true, + } + + err := scaffold(opts) + if err != nil { + t.Fatalf("scaffold error: %v", err) + } + + // Verify all expected files exist + expectedFiles := []string{ + "forge.yaml", + "main.go", + "tools/example_tool.go", + "skills.md", + ".env.example", + ".gitignore", + } + + for _, f := range expectedFiles { + path := filepath.Join("test-agent", f) + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Errorf("expected file %s to exist", path) + } + } + + // Verify forge.yaml is parseable by LoadForgeConfig + cfg, err := config.LoadForgeConfig(filepath.Join("test-agent", "forge.yaml")) + if err != nil { + t.Fatalf("LoadForgeConfig error: %v", err) + } + if cfg.AgentID != "test-agent" { + t.Errorf("expected agent_id = test-agent, got %q", cfg.AgentID) + } + if cfg.Framework != "custom" { + t.Errorf("expected framework = custom, got %q", cfg.Framework) + } + if cfg.Entrypoint != "go run main.go" { + t.Errorf("expected entrypoint = 'go run main.go', got %q", cfg.Entrypoint) + } + if cfg.Model.Provider != "openai" { + t.Errorf("expected model.provider = openai, got %q", cfg.Model.Provider) + } + if len(cfg.Channels) != 1 || cfg.Channels[0] != "slack" { + t.Errorf("expected channels = [slack], got %v", cfg.Channels) + } + if len(cfg.Tools) != 1 || cfg.Tools[0].Name != "web_search" { + t.Errorf("expected tools = [{web_search custom}], got %v", cfg.Tools) + } +} + +func TestScaffoldLangchainWithSkills(t *testing.T) { + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("chdir: %v", err) + } + defer func() { _ = os.Chdir(origDir) }() + + opts := &initOptions{ + Name: "My Agent", + AgentID: "my-agent", + Framework: "langchain", + Language: "python", + ModelProvider: "anthropic", + Tools: []toolEntry{ + {Name: "api_caller", Type: "custom"}, + }, + EnvVars: map[string]string{}, + NonInteractive: true, + } + + err := scaffold(opts) + if err != nil { + t.Fatalf("scaffold error: %v", err) + } + + cfg, err := config.LoadForgeConfig(filepath.Join("my-agent", "forge.yaml")) + if err != nil { + t.Fatalf("LoadForgeConfig error: %v", err) + } + if cfg.Entrypoint != "python agent.py" { + t.Errorf("expected entrypoint = 'python agent.py', got %q", cfg.Entrypoint) + } + if cfg.Model.Provider != "anthropic" { + t.Errorf("expected model.provider = anthropic, got %q", cfg.Model.Provider) + } +} + +func TestScaffold_GeneratesEnvFile(t *testing.T) { + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("chdir: %v", err) + } + defer func() { _ = os.Chdir(origDir) }() + + opts := &initOptions{ + Name: "env-test", + AgentID: "env-test", + Framework: "custom", + Language: "python", + ModelProvider: "openai", + APIKey: "sk-test123", + EnvVars: map[string]string{"OPENAI_API_KEY": "sk-test123"}, + NonInteractive: true, + } + + err := scaffold(opts) + if err != nil { + t.Fatalf("scaffold error: %v", err) + } + + envPath := filepath.Join("env-test", ".env") + content, err := os.ReadFile(envPath) + if err != nil { + t.Fatalf("reading .env: %v", err) + } + if !strings.Contains(string(content), "OPENAI_API_KEY=sk-test123") { + t.Errorf("expected .env to contain OPENAI_API_KEY=sk-test123, got:\n%s", content) + } +} + +func TestScaffold_VendorsSkills(t *testing.T) { + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("chdir: %v", err) + } + defer func() { _ = os.Chdir(origDir) }() + + opts := &initOptions{ + Name: "skill-test", + AgentID: "skill-test", + Framework: "custom", + Language: "python", + ModelProvider: "openai", + Skills: []string{"github"}, + EnvVars: map[string]string{}, + NonInteractive: true, + } + + err := scaffold(opts) + if err != nil { + t.Fatalf("scaffold error: %v", err) + } + + skillPath := filepath.Join("skill-test", "skills", "github.md") + content, err := os.ReadFile(skillPath) + if err != nil { + t.Fatalf("reading vendored skill: %v", err) + } + if !strings.Contains(string(content), "## Tool: github_create_issue") { + t.Errorf("vendored github.md missing expected tool heading") + } +} + +func TestScaffold_EgressInForgeYAML(t *testing.T) { + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("chdir: %v", err) + } + defer func() { _ = os.Chdir(origDir) }() + + opts := &initOptions{ + Name: "egress-test", + AgentID: "egress-test", + Framework: "custom", + Language: "python", + ModelProvider: "openai", + Channels: []string{"slack"}, + BuiltinTools: []string{"web_search"}, + EnvVars: map[string]string{}, + NonInteractive: true, + } + + err := scaffold(opts) + if err != nil { + t.Fatalf("scaffold error: %v", err) + } + + content, err := os.ReadFile(filepath.Join("egress-test", "forge.yaml")) + if err != nil { + t.Fatalf("reading forge.yaml: %v", err) + } + yamlStr := string(content) + if !strings.Contains(yamlStr, "allowed_domains") { + t.Error("forge.yaml missing egress allowed_domains section") + } + if !strings.Contains(yamlStr, "api.openai.com") { + t.Error("forge.yaml missing api.openai.com in egress domains") + } + if !strings.Contains(yamlStr, "api.perplexity.ai") { + t.Error("forge.yaml missing api.perplexity.ai in egress domains") + } +} + +func TestScaffold_GitignoreIncludesEnv(t *testing.T) { + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("chdir: %v", err) + } + defer func() { _ = os.Chdir(origDir) }() + + opts := &initOptions{ + Name: "gi-test", + AgentID: "gi-test", + Framework: "custom", + Language: "python", + ModelProvider: "openai", + EnvVars: map[string]string{}, + NonInteractive: true, + } + + err := scaffold(opts) + if err != nil { + t.Fatalf("scaffold error: %v", err) + } + + content, err := os.ReadFile(filepath.Join("gi-test", ".gitignore")) + if err != nil { + t.Fatalf("reading .gitignore: %v", err) + } + if !strings.Contains(string(content), ".env") { + t.Error(".gitignore missing .env entry") + } +} + +func TestDeriveEgressDomains(t *testing.T) { + opts := &initOptions{ + ModelProvider: "openai", + Channels: []string{"slack"}, + BuiltinTools: []string{"web_search"}, + EnvVars: map[string]string{}, + } + + skillInfos := lookupSelectedSkills([]string{"github"}) + domains := deriveEgressDomains(opts, skillInfos) + + expected := map[string]bool{ + "api.openai.com": true, + "slack.com": true, + "hooks.slack.com": true, + "api.slack.com": true, + "api.perplexity.ai": true, + "api.github.com": true, + "github.com": true, + } + for _, d := range domains { + if !expected[d] { + t.Errorf("unexpected domain: %s", d) + } + delete(expected, d) + } + for d := range expected { + t.Errorf("missing expected domain: %s", d) + } +} + +func TestDeriveEgressDomains_Empty(t *testing.T) { + opts := &initOptions{ + ModelProvider: "ollama", + EnvVars: map[string]string{}, + } + domains := deriveEgressDomains(opts, nil) + if len(domains) != 0 { + t.Errorf("expected empty domains for ollama with no tools/channels, got %v", domains) + } +} + +func TestBuildEnvVars(t *testing.T) { + opts := &initOptions{ + ModelProvider: "openai", + BuiltinTools: []string{"web_search"}, + Skills: []string{"github"}, + EnvVars: map[string]string{"OPENAI_API_KEY": "sk-test"}, + } + vars := buildEnvVars(opts) + + found := make(map[string]bool) + for _, v := range vars { + found[v.Key] = true + } + if !found["OPENAI_API_KEY"] { + t.Error("missing OPENAI_API_KEY") + } + if !found["PERPLEXITY_API_KEY"] { + t.Error("missing PERPLEXITY_API_KEY") + } + if !found["GH_TOKEN"] { + t.Error("missing GH_TOKEN") + } +} + +func TestContainsStr(t *testing.T) { + if !containsStr([]string{"a", "b", "c"}, "b") { + t.Error("expected true for 'b' in [a,b,c]") + } + if containsStr([]string{"a", "b", "c"}, "d") { + t.Error("expected false for 'd' in [a,b,c]") + } +} + +func TestTitleCase(t *testing.T) { + tests := []struct { + input, expected string + }{ + {"openai", "Openai"}, + {"anthropic", "Anthropic"}, + {"", ""}, + } + for _, tt := range tests { + got := titleCase(tt.input) + if got != tt.expected { + t.Errorf("titleCase(%q) = %q, want %q", tt.input, got, tt.expected) + } + } +} + +func assertContainsTemplate(t *testing.T, files []fileToRender, templatePath string) { + t.Helper() + for _, f := range files { + if f.TemplatePath == templatePath { + return + } + } + t.Errorf("expected file manifest to contain template %q", templatePath) +} + +func TestBuildTemplateData_DefaultModels(t *testing.T) { + tests := []struct { + provider string + expectedModel string + }{ + {"openai", "gpt-4o-mini"}, + {"anthropic", "claude-sonnet-4-20250514"}, + {"gemini", "gemini-2.5-flash"}, + {"ollama", "llama3"}, + } + + for _, tt := range tests { + t.Run(tt.provider, func(t *testing.T) { + opts := &initOptions{ + Name: "test", + AgentID: "test", + Framework: "custom", + Language: "python", + ModelProvider: tt.provider, + EnvVars: map[string]string{}, + } + data := buildTemplateData(opts) + if data.ModelName != tt.expectedModel { + t.Errorf("model: got %q, want %q", data.ModelName, tt.expectedModel) + } + }) + } +} + +func TestCollectNonInteractive_GeminiProvider(t *testing.T) { + opts := &initOptions{ + Name: "test", + ModelProvider: "gemini", + APIKey: "gem-key", + EnvVars: map[string]string{}, + } + err := collectNonInteractive(opts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts.EnvVars["GEMINI_API_KEY"] != "gem-key" { + t.Errorf("expected GEMINI_API_KEY=gem-key, got %q", opts.EnvVars["GEMINI_API_KEY"]) + } +} + +func TestBuildEnvVars_Gemini(t *testing.T) { + opts := &initOptions{ + ModelProvider: "gemini", + EnvVars: map[string]string{"GEMINI_API_KEY": "gem-test"}, + } + vars := buildEnvVars(opts) + + found := false + for _, v := range vars { + if v.Key == "GEMINI_API_KEY" && v.Value == "gem-test" { + found = true + } + } + if !found { + t.Error("missing GEMINI_API_KEY in env vars") + } +} + +func TestScaffold_ForceOverwrite(t *testing.T) { + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("chdir: %v", err) + } + defer func() { _ = os.Chdir(origDir) }() + + // Create existing directory + _ = os.MkdirAll("force-test", 0o755) + + opts := &initOptions{ + Name: "force-test", + AgentID: "force-test", + Framework: "custom", + Language: "python", + ModelProvider: "openai", + EnvVars: map[string]string{}, + NonInteractive: true, + Force: true, + } + + err := scaffold(opts) + if err != nil { + t.Fatalf("scaffold with --force should succeed: %v", err) + } +} + +func TestScaffold_ExistingDirBlocked(t *testing.T) { + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("chdir: %v", err) + } + defer func() { _ = os.Chdir(origDir) }() + + // Create existing directory + _ = os.MkdirAll("blocked-test", 0o755) + + opts := &initOptions{ + Name: "blocked-test", + AgentID: "blocked-test", + Framework: "custom", + Language: "python", + ModelProvider: "openai", + EnvVars: map[string]string{}, + NonInteractive: true, + Force: false, + } + + err := scaffold(opts) + if err == nil { + t.Fatal("expected error when directory exists without --force") + } + if !strings.Contains(err.Error(), "already exists") { + t.Errorf("expected 'already exists' error, got: %v", err) + } +} + +func writeTempFile(t *testing.T, name, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("writing temp file: %v", err) + } + return path +} diff --git a/forge-cli/cmd/init_validate.go b/forge-cli/cmd/init_validate.go new file mode 100644 index 0000000..f814ceb --- /dev/null +++ b/forge-cli/cmd/init_validate.go @@ -0,0 +1,175 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// providerValidationURLs maps provider names to their validation endpoints. +// Exported as variables to allow overriding in tests. +var ( + openaiValidationURL = "https://api.openai.com/v1/models" + anthropicValidationURL = "https://api.anthropic.com/v1/messages" + geminiValidationURL = "https://generativelanguage.googleapis.com/v1beta/models" + ollamaValidationURL = "http://localhost:11434/api/tags" + perplexityValidationURL = "https://api.perplexity.ai/chat/completions" +) + +// validateProviderKey validates an API key against the specified provider. +// Returns nil on success, a descriptive error on failure. +func validateProviderKey(provider, apiKey string) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + switch provider { + case "openai": + return validateOpenAIKey(ctx, apiKey) + case "anthropic": + return validateAnthropicKey(ctx, apiKey) + case "gemini": + return validateGeminiKey(ctx, apiKey) + case "ollama": + return validateOllamaConnection(ctx) + case "custom": + return nil // no validation for custom providers + default: + return fmt.Errorf("unknown provider %q", provider) + } +} + +func validateOpenAIKey(ctx context.Context, apiKey string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, openaiValidationURL, nil) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+apiKey) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("connecting to OpenAI: %w", err) + } + defer func() { _ = resp.Body.Close() }() + _, _ = io.Copy(io.Discard, resp.Body) + + if resp.StatusCode == http.StatusUnauthorized { + return fmt.Errorf("invalid OpenAI API key (401 Unauthorized)") + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("OpenAI API returned status %d", resp.StatusCode) + } + return nil +} + +func validateAnthropicKey(ctx context.Context, apiKey string) error { + // Use a minimal messages request to validate the key. + body := map[string]any{ + "model": "claude-sonnet-4-20250514", + "max_tokens": 1, + "messages": []map[string]string{{"role": "user", "content": "hi"}}, + } + bodyBytes, _ := json.Marshal(body) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, anthropicValidationURL, bytes.NewReader(bodyBytes)) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", apiKey) + req.Header.Set("anthropic-version", "2023-06-01") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("connecting to Anthropic: %w", err) + } + defer func() { _ = resp.Body.Close() }() + _, _ = io.Copy(io.Discard, resp.Body) + + if resp.StatusCode == http.StatusUnauthorized { + return fmt.Errorf("invalid Anthropic API key (401 Unauthorized)") + } + // A 200 or even 400 (bad request shape) means the key itself is valid + if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusBadRequest { + return nil + } + return fmt.Errorf("anthropic API returned status %d", resp.StatusCode) +} + +func validateGeminiKey(ctx context.Context, apiKey string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, geminiValidationURL+"?key="+apiKey, nil) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("connecting to Gemini: %w", err) + } + defer func() { _ = resp.Body.Close() }() + _, _ = io.Copy(io.Discard, resp.Body) + + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + return fmt.Errorf("invalid Gemini API key (%d)", resp.StatusCode) + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("gemini API returned status %d", resp.StatusCode) + } + return nil +} + +func validateOllamaConnection(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, ollamaValidationURL, nil) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("connecting to Ollama at %s: %w", ollamaValidationURL, err) + } + defer func() { _ = resp.Body.Close() }() + _, _ = io.Copy(io.Discard, resp.Body) + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("ollama returned status %d", resp.StatusCode) + } + return nil +} + +// validatePerplexityKey validates a Perplexity API key with a minimal request. +func validatePerplexityKey(apiKey string) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + body := map[string]any{ + "model": "sonar", + "messages": []map[string]string{{"role": "user", "content": "ping"}}, + } + bodyBytes, _ := json.Marshal(body) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, perplexityValidationURL, bytes.NewReader(bodyBytes)) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+apiKey) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("connecting to Perplexity: %w", err) + } + defer func() { _ = resp.Body.Close() }() + _, _ = io.Copy(io.Discard, resp.Body) + + if resp.StatusCode == http.StatusUnauthorized { + return fmt.Errorf("invalid Perplexity API key (401 Unauthorized)") + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("perplexity API returned status %d", resp.StatusCode) + } + return nil +} diff --git a/forge-cli/cmd/init_validate_test.go b/forge-cli/cmd/init_validate_test.go new file mode 100644 index 0000000..da707d5 --- /dev/null +++ b/forge-cli/cmd/init_validate_test.go @@ -0,0 +1,170 @@ +package cmd + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestValidateProviderKey_OpenAI_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer valid-key" { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data": []}`)) + })) + defer server.Close() + + orig := openaiValidationURL + openaiValidationURL = server.URL + defer func() { openaiValidationURL = orig }() + + err := validateProviderKey("openai", "valid-key") + if err != nil { + t.Fatalf("expected nil error, got: %v", err) + } +} + +func TestValidateProviderKey_OpenAI_Unauthorized(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + + orig := openaiValidationURL + openaiValidationURL = server.URL + defer func() { openaiValidationURL = orig }() + + err := validateProviderKey("openai", "bad-key") + if err == nil { + t.Fatal("expected error for unauthorized key") + } + if !strings.Contains(err.Error(), "invalid") { + t.Errorf("expected error containing 'invalid', got: %v", err) + } +} + +func TestValidateProviderKey_Anthropic_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("x-api-key") != "valid-key" { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"id": "msg_test"}`)) + })) + defer server.Close() + + orig := anthropicValidationURL + anthropicValidationURL = server.URL + defer func() { anthropicValidationURL = orig }() + + err := validateProviderKey("anthropic", "valid-key") + if err != nil { + t.Fatalf("expected nil error, got: %v", err) + } +} + +func TestValidateProviderKey_Anthropic_Unauthorized(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + + orig := anthropicValidationURL + anthropicValidationURL = server.URL + defer func() { anthropicValidationURL = orig }() + + err := validateProviderKey("anthropic", "bad-key") + if err == nil { + t.Fatal("expected error for unauthorized key") + } + if !strings.Contains(err.Error(), "invalid") { + t.Errorf("expected error containing 'invalid', got: %v", err) + } +} + +func TestValidateProviderKey_Ollama_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"models": []}`)) + })) + defer server.Close() + + orig := ollamaValidationURL + ollamaValidationURL = server.URL + defer func() { ollamaValidationURL = orig }() + + err := validateProviderKey("ollama", "") + if err != nil { + t.Fatalf("expected nil error, got: %v", err) + } +} + +func TestValidateProviderKey_Custom_AlwaysSucceeds(t *testing.T) { + err := validateProviderKey("custom", "any-key") + if err != nil { + t.Fatalf("expected nil error for custom provider, got: %v", err) + } +} + +func TestValidateProviderKey_Timeout(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(15 * time.Second) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + orig := openaiValidationURL + openaiValidationURL = server.URL + defer func() { openaiValidationURL = orig }() + + err := validateProviderKey("openai", "test-key") + if err == nil { + t.Fatal("expected timeout error") + } +} + +func TestValidatePerplexityKey_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer valid-key" { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"choices": [{"message": {"content": "pong"}}]}`)) + })) + defer server.Close() + + orig := perplexityValidationURL + perplexityValidationURL = server.URL + defer func() { perplexityValidationURL = orig }() + + err := validatePerplexityKey("valid-key") + if err != nil { + t.Fatalf("expected nil error, got: %v", err) + } +} + +func TestValidatePerplexityKey_Unauthorized(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + + orig := perplexityValidationURL + perplexityValidationURL = server.URL + defer func() { perplexityValidationURL = orig }() + + err := validatePerplexityKey("bad-key") + if err == nil { + t.Fatal("expected error for unauthorized key") + } + if !strings.Contains(err.Error(), "invalid") { + t.Errorf("expected error containing 'invalid', got: %v", err) + } +} diff --git a/forge-cli/cmd/package.go b/forge-cli/cmd/package.go new file mode 100644 index 0000000..f59df66 --- /dev/null +++ b/forge-cli/cmd/package.go @@ -0,0 +1,328 @@ +package cmd + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "text/template" + "time" + + "github.com/initializ/forge/forge-cli/config" + "github.com/initializ/forge/forge-cli/container" + "github.com/initializ/forge/forge-cli/templates" + "github.com/initializ/forge/forge-core/export" + "github.com/initializ/forge/forge-core/types" + "github.com/spf13/cobra" +) + +var ( + pushImage bool + platform string + noCache bool + devMode bool + prodMode bool + verifyFlag bool + registry string + builderArg string + skipBuild bool + withChannels bool +) + +var packageCmd = &cobra.Command{ + Use: "package", + Short: "Build a container image for the agent", + Long: "Package builds a container image from the forge build output. It auto-detects docker/podman/buildah.", + RunE: runPackage, +} + +func init() { + packageCmd.Flags().BoolVar(&pushImage, "push", false, "push image to registry after building") + packageCmd.Flags().StringVar(&platform, "platform", "", "target platform (e.g., linux/amd64)") + packageCmd.Flags().BoolVar(&noCache, "no-cache", false, "disable layer cache") + packageCmd.Flags().BoolVar(&devMode, "dev", false, "include dev tools in image") + packageCmd.Flags().BoolVar(&prodMode, "prod", false, "production build: reject dev tools and dev-open egress") + packageCmd.Flags().BoolVar(&verifyFlag, "verify", false, "smoke-test container after build") + packageCmd.Flags().StringVar(®istry, "registry", "", "registry prefix (e.g., ghcr.io/org)") + packageCmd.Flags().StringVar(&builderArg, "builder", "", "force specific builder (docker, podman, buildah)") + packageCmd.Flags().BoolVar(&skipBuild, "skip-build", false, "skip re-running forge build") + packageCmd.Flags().BoolVar(&withChannels, "with-channels", false, "generate docker-compose.yaml with channel adapters") +} + +func runPackage(cmd *cobra.Command, args []string) error { + // Mutual exclusivity check + if devMode && prodMode { + return fmt.Errorf("--dev and --prod flags are mutually exclusive") + } + + cfgPath := cfgFile + if !filepath.IsAbs(cfgPath) { + wd, err := os.Getwd() + if err != nil { + return fmt.Errorf("getting working directory: %w", err) + } + cfgPath = filepath.Join(wd, cfgPath) + } + + cfg, err := config.LoadForgeConfig(cfgPath) + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + // Production validation + if prodMode { + if err := validateProdConfig(cfg); err != nil { + return fmt.Errorf("production validation failed: %w", err) + } + } + + outDir := outputDir + if outDir == "." { + outDir = filepath.Join(filepath.Dir(cfgPath), ".forge-output") + } + + // Use registry from flag, fall back to config + reg := registry + if reg == "" { + reg = cfg.Registry + } + + // Check if build output exists and is fresh + if !skipBuild { + if err := ensureBuildOutput(outDir, cfgPath); err != nil { + return err + } + } else { + // Even with --skip-build, the output dir must exist + if _, err := os.Stat(filepath.Join(outDir, "build-manifest.json")); os.IsNotExist(err) { + return fmt.Errorf("build output not found at %s; run 'forge build' first or remove --skip-build", outDir) + } + } + + // Detect or select builder + var builder container.Builder + if builderArg != "" { + builder = container.Get(builderArg) + if builder == nil { + return fmt.Errorf("unknown builder: %s (supported: docker, podman, buildah)", builderArg) + } + if !builder.Available() { + return fmt.Errorf("builder %s is not available; ensure it is installed and running", builderArg) + } + } else { + builder = container.Detect() + if builder == nil { + return fmt.Errorf("no container builder found; install docker, podman, or buildah") + } + } + + // Compute image tag + imageTag := computeImageTag(cfg.AgentID, cfg.Version, reg) + + // Build args + buildArgs := map[string]string{} + if devMode { + buildArgs["FORGE_DEV"] = "true" + } + if cfg.Egress.Profile != "" { + buildArgs["EGRESS_PROFILE"] = cfg.Egress.Profile + } + if cfg.Egress.Mode != "" { + buildArgs["EGRESS_MODE"] = cfg.Egress.Mode + } + + // Build container image + fmt.Printf("Building image %s using %s...\n", imageTag, builder.Name()) + + result, err := builder.Build(context.Background(), container.BuildOptions{ + ContextDir: outDir, + Dockerfile: filepath.Join(outDir, "Dockerfile"), + Tag: imageTag, + Platform: platform, + NoCache: noCache, + BuildArgs: buildArgs, + }) + if err != nil { + return fmt.Errorf("container build failed: %w", err) + } + + fmt.Printf("Image built: %s (ID: %s)\n", result.Tag, result.ImageID) + + // Optionally push + pushed := false + if pushImage { + fmt.Printf("Pushing %s...\n", imageTag) + if err := builder.Push(context.Background(), imageTag); err != nil { + return fmt.Errorf("push failed: %w", err) + } + pushed = true + fmt.Printf("Pushed: %s\n", imageTag) + } + + // Write image manifest + manifest := &container.ImageManifest{ + AgentID: cfg.AgentID, + Version: cfg.Version, + ImageTag: imageTag, + Builder: builder.Name(), + Platform: platform, + BuiltAt: time.Now().UTC().Format(time.RFC3339), + BuildDir: outDir, + Pushed: pushed, + + ForgeVersion: "1.0", + ToolInterfaceVersion: "1.0", + EgressProfile: cfg.Egress.Profile, + EgressMode: cfg.Egress.Mode, + DevBuild: devMode, + } + + manifestPath := filepath.Join(outDir, "image-manifest.json") + if err := container.WriteManifest(manifestPath, manifest); err != nil { + return fmt.Errorf("writing image manifest: %w", err) + } + + // Generate docker-compose.yaml if --with-channels is set + if withChannels && len(cfg.Channels) > 0 { + composePath := filepath.Join(outDir, "docker-compose.yaml") + if err := generateDockerCompose(composePath, imageTag, cfg, 8080); err != nil { + return fmt.Errorf("generating docker-compose.yaml: %w", err) + } + fmt.Printf("Generated %s\n", composePath) + } + + // Verify container if requested + if verifyFlag { + fmt.Println("Verifying container...") + if err := container.Verify(context.Background(), imageTag); err != nil { + return fmt.Errorf("container verification failed: %w", err) + } + fmt.Println("Container verification passed.") + } + + fmt.Println("Package complete.") + return nil +} + +// validateProdConfig checks that the config is valid for production builds. +func validateProdConfig(cfg *types.ForgeConfig) error { + var toolNames []string + for _, t := range cfg.Tools { + toolNames = append(toolNames, t.Name) + } + v := export.ValidateProdConfig(cfg.Egress.Mode, toolNames) + if len(v.Errors) > 0 { + return fmt.Errorf("%s", v.Errors[0]) + } + return nil +} + +// channelComposeData holds data for a channel adapter in docker-compose. +type channelComposeData struct { + Name string + EnvVars []string +} + +// composeData holds template data for docker-compose generation. +type composeData struct { + ImageTag string + Port int + ModelProvider string + ModelName string + EgressProfile string + EgressMode string + Channels []channelComposeData +} + +func generateDockerCompose(path string, imageTag string, cfg *types.ForgeConfig, port int) error { + if port == 0 { + port = 8080 + } + + tmplContent, err := templates.FS.ReadFile("docker-compose.yaml.tmpl") + if err != nil { + return fmt.Errorf("reading docker-compose template: %w", err) + } + + tmpl, err := template.New("docker-compose").Parse(string(tmplContent)) + if err != nil { + return fmt.Errorf("parsing docker-compose template: %w", err) + } + + var channels []channelComposeData + for _, ch := range cfg.Channels { + if ch != "slack" && ch != "telegram" { + continue + } + cd := channelComposeData{Name: ch} + switch ch { + case "slack": + cd.EnvVars = []string{ + "SLACK_SIGNING_SECRET=${SLACK_SIGNING_SECRET}", + "SLACK_BOT_TOKEN=${SLACK_BOT_TOKEN}", + } + case "telegram": + cd.EnvVars = []string{ + "TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}", + } + } + channels = append(channels, cd) + } + + data := composeData{ + ImageTag: imageTag, + Port: port, + ModelProvider: cfg.Model.Provider, + ModelName: cfg.Model.Name, + EgressProfile: cfg.Egress.Profile, + EgressMode: cfg.Egress.Mode, + Channels: channels, + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return fmt.Errorf("executing docker-compose template: %w", err) + } + + return os.WriteFile(path, buf.Bytes(), 0644) +} + +// ensureBuildOutput runs forge build if output is missing or stale. +func ensureBuildOutput(outDir, cfgPath string) error { + manifestPath := filepath.Join(outDir, "build-manifest.json") + needsBuild := false + + info, err := os.Stat(manifestPath) + if os.IsNotExist(err) { + needsBuild = true + } else if err != nil { + return fmt.Errorf("checking build manifest: %w", err) + } else { + // Check if forge.yaml is newer than build manifest + cfgInfo, err := os.Stat(cfgPath) + if err != nil { + return fmt.Errorf("checking config file: %w", err) + } + if cfgInfo.ModTime().After(info.ModTime()) { + needsBuild = true + } + } + + if needsBuild { + fmt.Println("Running forge build...") + if err := runBuild(nil, nil); err != nil { + return fmt.Errorf("build step failed: %w", err) + } + } + + return nil +} + +// computeImageTag constructs the image tag from agent ID, version, and optional registry. +func computeImageTag(agentID, version, reg string) string { + if reg != "" { + return fmt.Sprintf("%s/%s:%s", reg, agentID, version) + } + return fmt.Sprintf("%s:%s", agentID, version) +} diff --git a/forge-cli/cmd/package_test.go b/forge-cli/cmd/package_test.go new file mode 100644 index 0000000..79dab4f --- /dev/null +++ b/forge-cli/cmd/package_test.go @@ -0,0 +1,245 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/initializ/forge/forge-core/types" +) + +func TestComputeImageTag(t *testing.T) { + tests := []struct { + name string + agentID string + version string + registry string + want string + }{ + { + name: "without registry", + agentID: "my-agent", + version: "1.0.0", + want: "my-agent:1.0.0", + }, + { + name: "with registry", + agentID: "my-agent", + version: "1.0.0", + registry: "ghcr.io/org", + want: "ghcr.io/org/my-agent:1.0.0", + }, + { + name: "with docker hub registry", + agentID: "my-agent", + version: "0.2.0", + registry: "docker.io/myuser", + want: "docker.io/myuser/my-agent:0.2.0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := computeImageTag(tt.agentID, tt.version, tt.registry) + if got != tt.want { + t.Errorf("computeImageTag() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestEnsureBuildOutput_MissingManifest(t *testing.T) { + dir := t.TempDir() + cfgPath := writeTestForgeYAML(t, dir, ` +agent_id: test-agent +version: 0.1.0 +framework: langchain +entrypoint: python agent.py +model: + provider: openai + name: gpt-4 +tools: + - name: web-search +`) + + outDir := filepath.Join(dir, ".forge-output") + + // Set globals for runBuild + oldCfg := cfgFile + cfgFile = cfgPath + defer func() { cfgFile = oldCfg }() + + oldOut := outputDir + outputDir = outDir + defer func() { outputDir = oldOut }() + + // ensureBuildOutput should trigger a build since manifest is missing + err := ensureBuildOutput(outDir, cfgPath) + if err != nil { + t.Fatalf("ensureBuildOutput() error: %v", err) + } + + // build-manifest.json should now exist + if _, err := os.Stat(filepath.Join(outDir, "build-manifest.json")); os.IsNotExist(err) { + t.Error("build-manifest.json not created after ensureBuildOutput") + } +} + +func TestEnsureBuildOutput_FreshManifest(t *testing.T) { + dir := t.TempDir() + cfgPath := writeTestForgeYAML(t, dir, ` +agent_id: test-agent +version: 0.1.0 +framework: langchain +entrypoint: python agent.py +model: + provider: openai + name: gpt-4 +tools: + - name: web-search +`) + + outDir := filepath.Join(dir, ".forge-output") + + // Set globals for runBuild + oldCfg := cfgFile + cfgFile = cfgPath + defer func() { cfgFile = oldCfg }() + + oldOut := outputDir + outputDir = outDir + defer func() { outputDir = oldOut }() + + // First call should build + if err := ensureBuildOutput(outDir, cfgPath); err != nil { + t.Fatalf("first ensureBuildOutput() error: %v", err) + } + + // Second call should skip build since manifest is fresh + if err := ensureBuildOutput(outDir, cfgPath); err != nil { + t.Fatalf("second ensureBuildOutput() error: %v", err) + } +} + +func TestWithChannelsFlagDefault(t *testing.T) { + if withChannels { + t.Error("--with-channels should default to false") + } +} + +func TestGenerateDockerCompose(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "docker-compose.yaml") + + cfg := &types.ForgeConfig{ + AgentID: "my-agent", + Version: "0.1.0", + Entrypoint: "python main.py", + Channels: []string{"a2a", "slack", "telegram"}, + Model: types.ModelRef{ + Provider: "openai", + Name: "gpt-4", + }, + Egress: types.EgressRef{ + Profile: "standard", + Mode: "allowlist", + }, + } + + err := generateDockerCompose(path, "my-agent:0.1.0", cfg, 8080) + if err != nil { + t.Fatalf("generateDockerCompose() error: %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading docker-compose.yaml: %v", err) + } + + content := string(data) + + // Check agent service + if !strings.Contains(content, "image: my-agent:0.1.0") { + t.Error("missing agent image") + } + if !strings.Contains(content, "\"8080:8080\"") { + t.Error("missing agent port mapping") + } + + // Check model env vars + if !strings.Contains(content, "FORGE_PROVIDER=openai") { + t.Error("missing FORGE_PROVIDER env var") + } + if !strings.Contains(content, "FORGE_MODEL=gpt-4") { + t.Error("missing FORGE_MODEL env var") + } + if !strings.Contains(content, "FORGE_API_KEY=${FORGE_API_KEY}") { + t.Error("missing FORGE_API_KEY env var") + } + + // Check egress labels + if !strings.Contains(content, "forge.egress.profile: standard") { + t.Error("missing egress profile label") + } + if !strings.Contains(content, "forge.egress.mode: allowlist") { + t.Error("missing egress mode label") + } + + // Check slack adapter + if !strings.Contains(content, "slack-adapter:") { + t.Error("missing slack-adapter service") + } + if !strings.Contains(content, "SLACK_SIGNING_SECRET") { + t.Error("missing SLACK_SIGNING_SECRET env var") + } + if !strings.Contains(content, "SLACK_BOT_TOKEN") { + t.Error("missing SLACK_BOT_TOKEN env var") + } + + // Check telegram adapter + if !strings.Contains(content, "telegram-adapter:") { + t.Error("missing telegram-adapter service") + } + if !strings.Contains(content, "TELEGRAM_BOT_TOKEN") { + t.Error("missing TELEGRAM_BOT_TOKEN env var") + } + + // Non-adapter channels (a2a) should be skipped + if strings.Contains(content, "a2a-adapter") { + t.Error("a2a should not generate an adapter service") + } + + // All adapters should depend on agent + if !strings.Contains(content, "depends_on:") { + t.Error("adapters should depend on agent") + } + if !strings.Contains(content, "AGENT_URL=http://agent:8080") { + t.Error("adapters should reference agent URL") + } +} + +func TestGenerateDockerCompose_NoAdapters(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "docker-compose.yaml") + + cfg := &types.ForgeConfig{ + AgentID: "my-agent", + Version: "0.1.0", + Entrypoint: "python main.py", + Channels: []string{"a2a", "http"}, + } + + err := generateDockerCompose(path, "my-agent:0.1.0", cfg, 8080) + if err != nil { + t.Fatalf("generateDockerCompose() error: %v", err) + } + + data, _ := os.ReadFile(path) + content := string(data) + + // Only agent service, no adapters + if strings.Contains(content, "-adapter:") { + t.Error("should not generate adapter services for non-adapter channels") + } +} diff --git a/forge-cli/cmd/root.go b/forge-cli/cmd/root.go new file mode 100644 index 0000000..c8b144f --- /dev/null +++ b/forge-cli/cmd/root.go @@ -0,0 +1,53 @@ +// Package cmd implements the forge CLI commands. +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var ( + cfgFile string + verbose bool + outputDir string + + appVersion = "dev" +) + +var rootCmd = &cobra.Command{ + Use: "forge", + Short: "Forge — scaffold, build, and deploy AI agents", + Long: "Forge is a CLI tool for initializing, building, validating, and deploying AI agent projects.", +} + +func init() { + rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "forge.yaml", "config file path") + rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "enable verbose output") + rootCmd.PersistentFlags().StringVarP(&outputDir, "output-dir", "o", ".", "output directory") + + rootCmd.AddCommand(initCmd) + rootCmd.AddCommand(validateCmd) + rootCmd.AddCommand(buildCmd) + rootCmd.AddCommand(runCmd) + rootCmd.AddCommand(toolCmd) + rootCmd.AddCommand(packageCmd) + rootCmd.AddCommand(exportCmd) + rootCmd.AddCommand(channelCmd) + rootCmd.AddCommand(skillsCmd) +} + +// SetVersionInfo sets the version and commit for display. +func SetVersionInfo(version, commit string) { + appVersion = version + rootCmd.Version = version + rootCmd.SetVersionTemplate(fmt.Sprintf("forge %s (commit: %s)\n", version, commit)) +} + +// Execute runs the root command. +func Execute() { + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/forge-cli/cmd/run.go b/forge-cli/cmd/run.go new file mode 100644 index 0000000..91b481c --- /dev/null +++ b/forge-cli/cmd/run.go @@ -0,0 +1,166 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + + "github.com/initializ/forge/forge-cli/channels" + "github.com/initializ/forge/forge-cli/config" + "github.com/initializ/forge/forge-cli/runtime" + "github.com/initializ/forge/forge-core/validate" + "github.com/spf13/cobra" +) + +var ( + runPort int + runMockTools bool + runEnforceGuardrails bool + runModel string + runProvider string + runEnvFile string + runWithChannels string +) + +var runCmd = &cobra.Command{ + Use: "run", + Short: "Run the agent locally with an A2A-compliant dev server", + RunE: runRun, +} + +func init() { + runCmd.Flags().IntVar(&runPort, "port", 8080, "port for the A2A dev server") + runCmd.Flags().BoolVar(&runMockTools, "mock-tools", false, "use mock runtime instead of subprocess") + runCmd.Flags().BoolVar(&runEnforceGuardrails, "enforce-guardrails", false, "enforce guardrail violations as errors") + runCmd.Flags().StringVar(&runModel, "model", "", "override model name (sets MODEL_NAME env var)") + runCmd.Flags().StringVar(&runProvider, "provider", "", "LLM provider (openai, anthropic, ollama)") + runCmd.Flags().StringVar(&runEnvFile, "env", ".env", "path to .env file") + runCmd.Flags().StringVar(&runWithChannels, "with", "", "comma-separated channel adapters to start (e.g. slack,telegram)") +} + +func runRun(cmd *cobra.Command, args []string) error { + cfgPath := cfgFile + if !filepath.IsAbs(cfgPath) { + wd, err := os.Getwd() + if err != nil { + return fmt.Errorf("getting working directory: %w", err) + } + cfgPath = filepath.Join(wd, cfgPath) + } + + cfg, err := config.LoadForgeConfig(cfgPath) + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + result := validate.ValidateForgeConfig(cfg) + if !result.IsValid() { + for _, e := range result.Errors { + fmt.Fprintf(os.Stderr, "ERROR: %s\n", e) + } + return fmt.Errorf("config validation failed: %d error(s)", len(result.Errors)) + } + + workDir := filepath.Dir(cfgPath) + + // Resolve env file path relative to workdir + envPath := runEnvFile + if !filepath.IsAbs(envPath) { + envPath = filepath.Join(workDir, envPath) + } + + // Load .env into process environment so channel adapters can resolve env vars + envVars, err := runtime.LoadEnvFile(envPath) + if err != nil { + return fmt.Errorf("loading env file: %w", err) + } + for k, v := range envVars { + if os.Getenv(k) == "" { + _ = os.Setenv(k, v) + } + } + + // Parse channel names from --with flag for banner display + var activeChannels []string + if runWithChannels != "" { + for _, name := range strings.Split(runWithChannels, ",") { + if n := strings.TrimSpace(name); n != "" { + activeChannels = append(activeChannels, n) + } + } + } + + runner, err := runtime.NewRunner(runtime.RunnerConfig{ + Config: cfg, + WorkDir: workDir, + Port: runPort, + MockTools: runMockTools, + EnforceGuardrails: runEnforceGuardrails, + ModelOverride: runModel, + ProviderOverride: runProvider, + EnvFilePath: envPath, + Verbose: verbose, + Channels: activeChannels, + }) + if err != nil { + return fmt.Errorf("creating runner: %w", err) + } + + // Set up signal handling + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigCh + fmt.Fprintln(os.Stderr, "\nShutting down...") + cancel() + }() + + // Start channel adapters if --with flag is set + if runWithChannels != "" { + registry := defaultRegistry() + agentURL := fmt.Sprintf("http://localhost:%d", runPort) + router := channels.NewRouter(agentURL) + + names := strings.Split(runWithChannels, ",") + for _, name := range names { + name = strings.TrimSpace(name) + if name == "" { + continue + } + + plugin := registry.Get(name) + if plugin == nil { + return fmt.Errorf("unknown channel adapter: %s", name) + } + + chCfgPath := filepath.Join(workDir, name+"-config.yaml") + chCfg, err := channels.LoadChannelConfig(chCfgPath) + if err != nil { + return fmt.Errorf("loading %s config: %w", name, err) + } + + if err := plugin.Init(*chCfg); err != nil { + return fmt.Errorf("initialising %s: %w", name, err) + } + + defer plugin.Stop() //nolint:errcheck + + go func() { + if err := plugin.Start(ctx, router.Handler()); err != nil { + fmt.Fprintf(os.Stderr, "channel %s error: %v\n", plugin.Name(), err) + } + }() + + fmt.Fprintf(os.Stderr, " Channel: %s adapter started\n", name) + } + } + + return runner.Run(ctx) +} diff --git a/forge-cli/cmd/run_test.go b/forge-cli/cmd/run_test.go new file mode 100644 index 0000000..26563eb --- /dev/null +++ b/forge-cli/cmd/run_test.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" +) + +func TestRunCmd_FlagDefaults(t *testing.T) { + if runPort != 8080 { + t.Errorf("default port: got %d, want 8080", runPort) + } + if runMockTools { + t.Error("mock-tools should default to false") + } + if runEnforceGuardrails { + t.Error("enforce-guardrails should default to false") + } + if runModel != "" { + t.Errorf("model should default to empty, got %q", runModel) + } + if runEnvFile != ".env" { + t.Errorf("env file should default to .env, got %q", runEnvFile) + } +} + +func TestRunCmd_InvalidConfig(t *testing.T) { + // Create a temp dir with no forge.yaml + dir := t.TempDir() + origDir, _ := os.Getwd() + os.Chdir(dir) //nolint:errcheck + defer os.Chdir(origDir) //nolint:errcheck + + err := runRun(nil, nil) + if err == nil { + t.Error("expected error for missing config") + } +} + +func TestRunCmd_WithFlagDefault(t *testing.T) { + if runWithChannels != "" { + t.Errorf("--with should default to empty, got %q", runWithChannels) + } +} + +func TestRunCmd_InvalidConfigContent(t *testing.T) { + dir := t.TempDir() + + // Write an invalid forge.yaml (missing required fields) + cfgContent := "framework: custom\n" + os.WriteFile(filepath.Join(dir, "forge.yaml"), []byte(cfgContent), 0644) //nolint:errcheck + + origDir, _ := os.Getwd() + os.Chdir(dir) //nolint:errcheck + defer os.Chdir(origDir) //nolint:errcheck + + err := runRun(nil, nil) + if err == nil { + t.Error("expected error for invalid config") + } +} diff --git a/forge-cli/cmd/skills.go b/forge-cli/cmd/skills.go new file mode 100644 index 0000000..a1793c1 --- /dev/null +++ b/forge-cli/cmd/skills.go @@ -0,0 +1,131 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/initializ/forge/forge-cli/config" + cliskills "github.com/initializ/forge/forge-cli/skills" + coreskills "github.com/initializ/forge/forge-core/skills" + "github.com/spf13/cobra" +) + +var skillsCmd = &cobra.Command{ + Use: "skills", + Short: "Manage and inspect agent skills", +} + +var skillsValidateCmd = &cobra.Command{ + Use: "validate", + Short: "Validate skills file and check requirements", + RunE: runSkillsValidate, +} + +func init() { + skillsCmd.AddCommand(skillsValidateCmd) +} + +func runSkillsValidate(cmd *cobra.Command, args []string) error { + // Determine skills file path + skillsPath := "skills.md" + + cfgPath := cfgFile + if !filepath.IsAbs(cfgPath) { + wd, _ := os.Getwd() + cfgPath = filepath.Join(wd, cfgPath) + } + cfg, err := config.LoadForgeConfig(cfgPath) + if err == nil && cfg.Skills.Path != "" { + skillsPath = cfg.Skills.Path + } + + if !filepath.IsAbs(skillsPath) { + wd, _ := os.Getwd() + skillsPath = filepath.Join(wd, skillsPath) + } + + // Parse with metadata + entries, _, err := cliskills.ParseFileWithMetadata(skillsPath) + if err != nil { + return fmt.Errorf("parsing skills file: %w", err) + } + + fmt.Printf("Skills file: %s\n", skillsPath) + fmt.Printf("Entries: %d\n\n", len(entries)) + + // Aggregate requirements + reqs := coreskills.AggregateRequirements(entries) + + hasErrors := false + + // Check binaries + if len(reqs.Bins) > 0 { + fmt.Println("Binaries:") + binDiags := coreskills.BinDiagnostics(reqs.Bins) + diagMap := make(map[string]string) + for _, d := range binDiags { + diagMap[d.Var] = d.Level + } + for _, bin := range reqs.Bins { + if _, missing := diagMap[bin]; missing { + fmt.Printf(" %-20s MISSING\n", bin) + } else { + fmt.Printf(" %-20s ok\n", bin) + } + } + fmt.Println() + } + + // Build env resolver from OS env + .env file + osEnv := envFromOS() + dotEnv := map[string]string{} + envFilePath := filepath.Join(filepath.Dir(skillsPath), ".env") + if f, fErr := os.Open(envFilePath); fErr == nil { + // Simple line-based .env parsing + defer func() { _ = f.Close() }() + // Use the runtime's LoadEnvFile indirectly — just check OS env for now + } + + resolver := coreskills.NewEnvResolver(osEnv, dotEnv, nil) + envDiags := resolver.Resolve(reqs) + + if len(reqs.EnvRequired) > 0 || len(reqs.EnvOneOf) > 0 || len(reqs.EnvOptional) > 0 { + fmt.Println("Environment:") + for _, d := range envDiags { + prefix := " " + switch d.Level { + case "error": + prefix = " ERROR" + hasErrors = true + case "warning": + prefix = " WARN " + } + fmt.Printf("%s %s\n", prefix, d.Message) + } + if len(envDiags) == 0 { + fmt.Println(" All environment requirements satisfied.") + } + fmt.Println() + } + + // Summary + if !hasErrors { + fmt.Println("Validation passed.") + return nil + } + + return fmt.Errorf("validation failed: missing required environment variables") +} + +func envFromOS() map[string]string { + env := make(map[string]string) + for _, e := range os.Environ() { + k, v, ok := strings.Cut(e, "=") + if ok { + env[k] = v + } + } + return env +} diff --git a/forge-cli/cmd/tool.go b/forge-cli/cmd/tool.go new file mode 100644 index 0000000..529c980 --- /dev/null +++ b/forge-cli/cmd/tool.go @@ -0,0 +1,71 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "text/tabwriter" + + "github.com/initializ/forge/forge-core/tools" + "github.com/initializ/forge/forge-core/tools/builtins" + "github.com/spf13/cobra" +) + +var toolCmd = &cobra.Command{ + Use: "tool", + Short: "Manage and inspect agent tools", +} + +var toolListCmd = &cobra.Command{ + Use: "list", + Short: "List all available tools", + RunE: toolListRun, +} + +var toolDescribeCmd = &cobra.Command{ + Use: "describe ", + Short: "Show tool details and schema", + Args: cobra.ExactArgs(1), + RunE: toolDescribeRun, +} + +func init() { + toolCmd.AddCommand(toolListCmd) + toolCmd.AddCommand(toolDescribeCmd) +} + +func toolListRun(cmd *cobra.Command, args []string) error { + reg := tools.NewRegistry() + if err := builtins.RegisterAll(reg); err != nil { + return fmt.Errorf("registering builtins: %w", err) + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintf(w, "NAME\tCATEGORY\tDESCRIPTION\n") + + for _, name := range reg.List() { + t := reg.Get(name) + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\n", t.Name(), t.Category(), t.Description()) + } + return w.Flush() +} + +func toolDescribeRun(cmd *cobra.Command, args []string) error { + name := args[0] + t := builtins.GetByName(name) + if t == nil { + return fmt.Errorf("unknown tool: %q", name) + } + + _, _ = fmt.Fprintf(os.Stdout, "Name: %s\n", t.Name()) + _, _ = fmt.Fprintf(os.Stdout, "Category: %s\n", t.Category()) + _, _ = fmt.Fprintf(os.Stdout, "Description: %s\n", t.Description()) + _, _ = fmt.Fprintf(os.Stdout, "\nInput Schema:\n") + + var pretty json.RawMessage + if json.Unmarshal(t.InputSchema(), &pretty) == nil { + data, _ := json.MarshalIndent(pretty, "", " ") + _, _ = fmt.Fprintf(os.Stdout, "%s\n", data) + } + return nil +} diff --git a/forge-cli/cmd/tool_test.go b/forge-cli/cmd/tool_test.go new file mode 100644 index 0000000..63626ca --- /dev/null +++ b/forge-cli/cmd/tool_test.go @@ -0,0 +1,48 @@ +package cmd + +import ( + "bytes" + "testing" +) + +func TestToolListCmd(t *testing.T) { + rootCmd.SetArgs([]string{"tool", "list"}) + var out bytes.Buffer + rootCmd.SetOut(&out) + rootCmd.SetErr(&out) + + err := rootCmd.Execute() + if err != nil { + t.Fatalf("tool list error: %v", err) + } + + output := out.String() + if output == "" { + // At minimum we should have the header line + t.Log("note: tool list produced no output (expected in some test configurations)") + } +} + +func TestToolDescribeCmd_KnownTool(t *testing.T) { + rootCmd.SetArgs([]string{"tool", "describe", "json_parse"}) + var out bytes.Buffer + rootCmd.SetOut(&out) + rootCmd.SetErr(&out) + + err := rootCmd.Execute() + if err != nil { + t.Fatalf("tool describe error: %v", err) + } +} + +func TestToolDescribeCmd_UnknownTool(t *testing.T) { + rootCmd.SetArgs([]string{"tool", "describe", "nonexistent_tool"}) + var out bytes.Buffer + rootCmd.SetOut(&out) + rootCmd.SetErr(&out) + + err := rootCmd.Execute() + if err == nil { + t.Fatal("expected error for unknown tool") + } +} diff --git a/forge-cli/cmd/validate.go b/forge-cli/cmd/validate.go new file mode 100644 index 0000000..e56720e --- /dev/null +++ b/forge-cli/cmd/validate.go @@ -0,0 +1,108 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/initializ/forge/forge-cli/config" + "github.com/initializ/forge/forge-core/agentspec" + "github.com/initializ/forge/forge-core/validate" + "github.com/spf13/cobra" +) + +var ( + strict bool + commandCompat bool +) + +var validateCmd = &cobra.Command{ + Use: "validate", + Short: "Validate the agent spec and forge.yaml", + RunE: runValidate, +} + +func init() { + validateCmd.Flags().BoolVar(&strict, "strict", false, "treat warnings as errors") + validateCmd.Flags().BoolVar(&commandCompat, "command-compat", false, "check Command platform import compatibility") +} + +func runValidate(cmd *cobra.Command, args []string) error { + cfgPath := cfgFile + if !filepath.IsAbs(cfgPath) { + wd, err := os.Getwd() + if err != nil { + return fmt.Errorf("getting working directory: %w", err) + } + cfgPath = filepath.Join(wd, cfgPath) + } + + cfg, err := config.LoadForgeConfig(cfgPath) + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + result := validate.ValidateForgeConfig(cfg) + + // Also validate agent.json if it exists + agentJSONPaths := []string{ + filepath.Join(filepath.Dir(cfgPath), ".forge-output", "agent.json"), + filepath.Join(filepath.Dir(cfgPath), "agent.json"), + } + for _, p := range agentJSONPaths { + data, err := os.ReadFile(p) + if err != nil { + continue + } + errs, err := validate.ValidateAgentSpec(data) + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("agent.json schema error: %v", err)) + break + } + for _, e := range errs { + result.Errors = append(result.Errors, fmt.Sprintf("agent.json: %s", e)) + } + break + } + + // Command compatibility check + if commandCompat { + agentJSONPath := filepath.Join(filepath.Dir(cfgPath), ".forge-output", "agent.json") + agentData, err := os.ReadFile(agentJSONPath) + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("command-compat: cannot read agent.json: %v (run 'forge build' first)", err)) + } else { + var spec agentspec.AgentSpec + if err := json.Unmarshal(agentData, &spec); err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("command-compat: cannot parse agent.json: %v", err)) + } else { + compatResult := validate.ValidateCommandCompat(&spec) + for _, e := range compatResult.Errors { + result.Errors = append(result.Errors, fmt.Sprintf("command-compat: %s", e)) + } + for _, w := range compatResult.Warnings { + result.Warnings = append(result.Warnings, fmt.Sprintf("command-compat: %s", w)) + } + } + } + } + + for _, w := range result.Warnings { + fmt.Fprintf(os.Stderr, "WARNING: %s\n", w) + } + for _, e := range result.Errors { + fmt.Fprintf(os.Stderr, "ERROR: %s\n", e) + } + + if strict && len(result.Warnings) > 0 { + return fmt.Errorf("validation failed: %d warning(s) treated as errors in strict mode", len(result.Warnings)) + } + + if !result.IsValid() { + return fmt.Errorf("validation failed: %d error(s)", len(result.Errors)) + } + + fmt.Println("Validation passed.") + return nil +} diff --git a/forge-cli/cmd/validate_test.go b/forge-cli/cmd/validate_test.go new file mode 100644 index 0000000..d1acea3 --- /dev/null +++ b/forge-cli/cmd/validate_test.go @@ -0,0 +1,212 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" +) + +func writeTestForgeYAML(t *testing.T, dir, content string) string { + t.Helper() + path := filepath.Join(dir, "forge.yaml") + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("writing forge.yaml: %v", err) + } + return path +} + +func TestRunValidate_ValidConfig(t *testing.T) { + dir := t.TempDir() + cfgPath := writeTestForgeYAML(t, dir, ` +agent_id: test-agent +version: 0.1.0 +framework: langchain +entrypoint: python agent.py +model: + provider: openai + name: gpt-4 +tools: + - name: web-search +`) + + // Override the global cfgFile + oldCfg := cfgFile + cfgFile = cfgPath + defer func() { cfgFile = oldCfg }() + + oldStrict := strict + strict = false + defer func() { strict = oldStrict }() + + err := runValidate(nil, nil) + if err != nil { + t.Fatalf("runValidate() error: %v", err) + } +} + +func TestRunValidate_InvalidConfig(t *testing.T) { + dir := t.TempDir() + cfgPath := writeTestForgeYAML(t, dir, ` +agent_id: INVALID_ID! +version: not-semver +entrypoint: "" +`) + + oldCfg := cfgFile + cfgFile = cfgPath + defer func() { cfgFile = oldCfg }() + + oldStrict := strict + strict = false + defer func() { strict = oldStrict }() + + err := runValidate(nil, nil) + if err == nil { + t.Fatal("expected error for invalid config") + } +} + +func TestRunValidate_StrictMode(t *testing.T) { + dir := t.TempDir() + cfgPath := writeTestForgeYAML(t, dir, ` +agent_id: test-agent +version: 0.1.0 +framework: autogen +entrypoint: python agent.py +model: + provider: openai + name: gpt-4 +`) + + oldCfg := cfgFile + cfgFile = cfgPath + defer func() { cfgFile = oldCfg }() + + oldStrict := strict + strict = true + defer func() { strict = oldStrict }() + + err := runValidate(nil, nil) + if err == nil { + t.Fatal("expected error in strict mode with unknown framework warning") + } +} + +func TestRunValidate_CommandCompat(t *testing.T) { + dir := t.TempDir() + cfgPath := writeTestForgeYAML(t, dir, ` +agent_id: test-agent +version: 0.1.0 +framework: custom +entrypoint: python agent.py +model: + provider: openai + name: gpt-4 +`) + + // Create .forge-output with a valid agent.json + outDir := filepath.Join(dir, ".forge-output") + if err := os.MkdirAll(outDir, 0755); err != nil { + t.Fatalf("creating .forge-output: %v", err) + } + agentJSON := `{ + "forge_version": "1.0", + "agent_id": "test-agent", + "version": "0.1.0", + "name": "Test Agent", + "runtime": {"image": "python:3.11-slim", "port": 8080}, + "model": {"provider": "openai", "name": "gpt-4"}, + "a2a": {"capabilities": {"streaming": true}} + }` + if err := os.WriteFile(filepath.Join(outDir, "agent.json"), []byte(agentJSON), 0644); err != nil { + t.Fatalf("writing agent.json: %v", err) + } + + oldCfg := cfgFile + cfgFile = cfgPath + defer func() { cfgFile = oldCfg }() + + oldStrict := strict + strict = false + defer func() { strict = oldStrict }() + + oldCompat := commandCompat + commandCompat = true + defer func() { commandCompat = oldCompat }() + + err := runValidate(nil, nil) + if err != nil { + t.Fatalf("runValidate() with --command-compat error: %v", err) + } +} + +func TestRunValidate_CommandCompat_MissingRuntime(t *testing.T) { + dir := t.TempDir() + cfgPath := writeTestForgeYAML(t, dir, ` +agent_id: test-agent +version: 0.1.0 +framework: custom +entrypoint: python agent.py +`) + + // agent.json without runtime + outDir := filepath.Join(dir, ".forge-output") + if err := os.MkdirAll(outDir, 0755); err != nil { + t.Fatalf("creating .forge-output: %v", err) + } + agentJSON := `{ + "forge_version": "1.0", + "agent_id": "test-agent", + "version": "0.1.0", + "name": "Test Agent" + }` + if err := os.WriteFile(filepath.Join(outDir, "agent.json"), []byte(agentJSON), 0644); err != nil { + t.Fatalf("writing agent.json: %v", err) + } + + oldCfg := cfgFile + cfgFile = cfgPath + defer func() { cfgFile = oldCfg }() + + oldStrict := strict + strict = false + defer func() { strict = oldStrict }() + + oldCompat := commandCompat + commandCompat = true + defer func() { commandCompat = oldCompat }() + + err := runValidate(nil, nil) + if err == nil { + t.Fatal("expected error for missing runtime in command-compat mode") + } +} + +func TestRunValidate_CommandCompat_NoBuild(t *testing.T) { + dir := t.TempDir() + cfgPath := writeTestForgeYAML(t, dir, ` +agent_id: test-agent +version: 0.1.0 +framework: custom +entrypoint: python agent.py +`) + + // No .forge-output directory + + oldCfg := cfgFile + cfgFile = cfgPath + defer func() { cfgFile = oldCfg }() + + oldStrict := strict + strict = false + defer func() { strict = oldStrict }() + + oldCompat := commandCompat + commandCompat = true + defer func() { commandCompat = oldCompat }() + + err := runValidate(nil, nil) + if err == nil { + t.Fatal("expected error when agent.json doesn't exist") + } +} diff --git a/forge-cli/config/forge_yaml.go b/forge-cli/config/forge_yaml.go new file mode 100644 index 0000000..74ed9a8 --- /dev/null +++ b/forge-cli/config/forge_yaml.go @@ -0,0 +1,17 @@ +package config + +import ( + "fmt" + "os" + + "github.com/initializ/forge/forge-core/types" +) + +// LoadForgeConfig reads and parses a forge.yaml file from the given path. +func LoadForgeConfig(path string) (*types.ForgeConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading forge config %s: %w", path, err) + } + return types.ParseForgeConfig(data) +} diff --git a/forge-cli/container/buildah.go b/forge-cli/container/buildah.go new file mode 100644 index 0000000..9f885d4 --- /dev/null +++ b/forge-cli/container/buildah.go @@ -0,0 +1,76 @@ +package container + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" +) + +// BuildahBuilder builds container images using the buildah CLI. +type BuildahBuilder struct{} + +func (b *BuildahBuilder) Name() string { return "buildah" } + +func (b *BuildahBuilder) Available() bool { + return exec.Command("buildah", "version").Run() == nil +} + +func (b *BuildahBuilder) Build(ctx context.Context, opts BuildOptions) (*BuildResult, error) { + args := []string{"bud"} + + if opts.Tag != "" { + args = append(args, "-t", opts.Tag) + } + if opts.Dockerfile != "" { + args = append(args, "-f", opts.Dockerfile) + } + if opts.Platform != "" { + args = append(args, "--platform", opts.Platform) + } + if opts.NoCache { + args = append(args, "--no-cache") + } + for k, v := range opts.BuildArgs { + args = append(args, "--build-arg", fmt.Sprintf("%s=%s", k, v)) + } + + contextDir := opts.ContextDir + if contextDir == "" { + contextDir = "." + } + args = append(args, contextDir) + + cmd := exec.CommandContext(ctx, "buildah", args...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("buildah bud failed: %s: %w", stderr.String(), err) + } + + // Buildah outputs the image ID on the last line + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + imageID := "" + if len(lines) > 0 { + imageID = strings.TrimSpace(lines[len(lines)-1]) + } + + return &BuildResult{ + ImageID: imageID, + Tag: opts.Tag, + }, nil +} + +func (b *BuildahBuilder) Push(ctx context.Context, image string) error { + cmd := exec.CommandContext(ctx, "buildah", "push", image) + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("buildah push failed: %s: %w", stderr.String(), err) + } + return nil +} diff --git a/forge-cli/container/builder.go b/forge-cli/container/builder.go new file mode 100644 index 0000000..c4a21a3 --- /dev/null +++ b/forge-cli/container/builder.go @@ -0,0 +1,59 @@ +// Package container provides container image building via docker, podman, or buildah. +package container + +import "context" + +// Builder is the interface for container image builders. +type Builder interface { + Build(ctx context.Context, opts BuildOptions) (*BuildResult, error) + Push(ctx context.Context, image string) error + Available() bool + Name() string +} + +// BuildOptions configures a container image build. +type BuildOptions struct { + ContextDir string + Dockerfile string + Tag string + Platform string + NoCache bool + BuildArgs map[string]string +} + +// BuildResult holds the result of a container image build. +type BuildResult struct { + ImageID string + Tag string + Size int64 +} + +// Detect returns the first available container builder in order: docker, podman, buildah. +// Returns nil if no builder is available. +func Detect() Builder { + builders := []Builder{ + &DockerBuilder{}, + &PodmanBuilder{}, + &BuildahBuilder{}, + } + for _, b := range builders { + if b.Available() { + return b + } + } + return nil +} + +// Get returns a builder by name, or nil if the name is unknown. +func Get(name string) Builder { + switch name { + case "docker": + return &DockerBuilder{} + case "podman": + return &PodmanBuilder{} + case "buildah": + return &BuildahBuilder{} + default: + return nil + } +} diff --git a/forge-cli/container/builder_test.go b/forge-cli/container/builder_test.go new file mode 100644 index 0000000..5000885 --- /dev/null +++ b/forge-cli/container/builder_test.go @@ -0,0 +1,57 @@ +package container + +import ( + "testing" +) + +func TestGet_KnownBuilders(t *testing.T) { + tests := []struct { + name string + expected string + }{ + {"docker", "docker"}, + {"podman", "podman"}, + {"buildah", "buildah"}, + } + + for _, tt := range tests { + b := Get(tt.name) + if b == nil { + t.Errorf("Get(%q) returned nil", tt.name) + continue + } + if b.Name() != tt.expected { + t.Errorf("Get(%q).Name() = %q, want %q", tt.name, b.Name(), tt.expected) + } + } +} + +func TestGet_UnknownBuilder(t *testing.T) { + b := Get("unknown") + if b != nil { + t.Errorf("Get(\"unknown\") = %v, want nil", b) + } +} + +func TestDetect_ReturnsBuilderOrNil(t *testing.T) { + // Detect should return a builder or nil depending on what's installed. + // We can't assert which builder is available in CI, but we can verify + // it doesn't panic and returns a valid type. + b := Detect() + if b != nil { + name := b.Name() + if name != "docker" && name != "podman" && name != "buildah" { + t.Errorf("Detect() returned builder with unexpected name: %q", name) + } + } +} + +func TestBuildOptions_Defaults(t *testing.T) { + opts := BuildOptions{} + if opts.ContextDir != "" { + t.Errorf("default ContextDir = %q, want empty", opts.ContextDir) + } + if opts.NoCache { + t.Error("default NoCache = true, want false") + } +} diff --git a/forge-cli/container/docker.go b/forge-cli/container/docker.go new file mode 100644 index 0000000..ac3988d --- /dev/null +++ b/forge-cli/container/docker.go @@ -0,0 +1,89 @@ +package container + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" +) + +// DockerBuilder builds container images using the docker CLI. +type DockerBuilder struct{} + +func (b *DockerBuilder) Name() string { return "docker" } + +func (b *DockerBuilder) Available() bool { + return exec.Command("docker", "info").Run() == nil +} + +func (b *DockerBuilder) Build(ctx context.Context, opts BuildOptions) (*BuildResult, error) { + args := []string{"build"} + + if opts.Tag != "" { + args = append(args, "-t", opts.Tag) + } + if opts.Dockerfile != "" { + args = append(args, "-f", opts.Dockerfile) + } + if opts.Platform != "" { + args = append(args, "--platform", opts.Platform) + } + if opts.NoCache { + args = append(args, "--no-cache") + } + for k, v := range opts.BuildArgs { + args = append(args, "--build-arg", fmt.Sprintf("%s=%s", k, v)) + } + + contextDir := opts.ContextDir + if contextDir == "" { + contextDir = "." + } + args = append(args, contextDir) + + cmd := exec.CommandContext(ctx, "docker", args...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("docker build failed: %s: %w", stderr.String(), err) + } + + imageID := parseImageID(string(out)) + return &BuildResult{ + ImageID: imageID, + Tag: opts.Tag, + }, nil +} + +func (b *DockerBuilder) Push(ctx context.Context, image string) error { + cmd := exec.CommandContext(ctx, "docker", "push", image) + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("docker push failed: %s: %w", stderr.String(), err) + } + return nil +} + +// parseImageID extracts the image ID from docker build output. +func parseImageID(output string) string { + lines := strings.Split(strings.TrimSpace(output), "\n") + for i := len(lines) - 1; i >= 0; i-- { + line := strings.TrimSpace(lines[i]) + // Docker outputs "Successfully built " or just a sha256 hash + if strings.HasPrefix(line, "Successfully built ") { + return strings.TrimPrefix(line, "Successfully built ") + } + if strings.HasPrefix(line, "sha256:") { + return line + } + } + if len(lines) > 0 { + return strings.TrimSpace(lines[len(lines)-1]) + } + return "" +} diff --git a/forge-cli/container/docker_test.go b/forge-cli/container/docker_test.go new file mode 100644 index 0000000..2969cd8 --- /dev/null +++ b/forge-cli/container/docker_test.go @@ -0,0 +1,64 @@ +package container + +import ( + "testing" +) + +func TestDockerBuilder_Name(t *testing.T) { + b := &DockerBuilder{} + if b.Name() != "docker" { + t.Errorf("Name() = %q, want %q", b.Name(), "docker") + } +} + +func TestPodmanBuilder_Name(t *testing.T) { + b := &PodmanBuilder{} + if b.Name() != "podman" { + t.Errorf("Name() = %q, want %q", b.Name(), "podman") + } +} + +func TestBuildahBuilder_Name(t *testing.T) { + b := &BuildahBuilder{} + if b.Name() != "buildah" { + t.Errorf("Name() = %q, want %q", b.Name(), "buildah") + } +} + +func TestParseImageID(t *testing.T) { + tests := []struct { + name string + output string + want string + }{ + { + name: "docker style", + output: "Step 1/5 : FROM alpine\nSuccessfully built abc123def", + want: "abc123def", + }, + { + name: "sha256 hash", + output: "Step 1/5 : FROM alpine\nsha256:abc123def456", + want: "sha256:abc123def456", + }, + { + name: "last line fallback", + output: "some-image-id", + want: "some-image-id", + }, + { + name: "empty output", + output: "", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseImageID(tt.output) + if got != tt.want { + t.Errorf("parseImageID() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/forge-cli/container/manifest.go b/forge-cli/container/manifest.go new file mode 100644 index 0000000..2390d3b --- /dev/null +++ b/forge-cli/container/manifest.go @@ -0,0 +1,55 @@ +package container + +import ( + "encoding/json" + "fmt" + "os" +) + +// ImageManifest records metadata about a built container image. +type ImageManifest struct { + AgentID string `json:"agent_id"` + Version string `json:"version"` + ImageTag string `json:"image_tag"` + Builder string `json:"builder"` + Platform string `json:"platform,omitempty"` + BuiltAt string `json:"built_at"` + BuildDir string `json:"build_dir"` + Pushed bool `json:"pushed"` + + // Container packaging extensions + ForgeVersion string `json:"forge_version,omitempty"` + ToolInterfaceVersion string `json:"tool_interface_version,omitempty"` + SkillsSpecVersion string `json:"skills_spec_version,omitempty"` + SkillsCount int `json:"skills_count,omitempty"` + EgressProfile string `json:"egress_profile,omitempty"` + EgressMode string `json:"egress_mode,omitempty"` + AllowedDomainsCount int `json:"allowed_domains_count,omitempty"` + DevBuild bool `json:"dev_build,omitempty"` + ToolCategories map[string]int `json:"tool_categories,omitempty"` +} + +// WriteManifest writes the image manifest as JSON to the given path. +func WriteManifest(path string, m *ImageManifest) error { + data, err := json.MarshalIndent(m, "", " ") + if err != nil { + return fmt.Errorf("marshalling image manifest: %w", err) + } + if err := os.WriteFile(path, data, 0644); err != nil { + return fmt.Errorf("writing image manifest: %w", err) + } + return nil +} + +// ReadManifest reads an image manifest from the given path. +func ReadManifest(path string) (*ImageManifest, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading image manifest: %w", err) + } + var m ImageManifest + if err := json.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("parsing image manifest: %w", err) + } + return &m, nil +} diff --git a/forge-cli/container/manifest_test.go b/forge-cli/container/manifest_test.go new file mode 100644 index 0000000..9b83ba1 --- /dev/null +++ b/forge-cli/container/manifest_test.go @@ -0,0 +1,78 @@ +package container + +import ( + "os" + "path/filepath" + "testing" +) + +func TestWriteAndReadManifest(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "image-manifest.json") + + original := &ImageManifest{ + AgentID: "test-agent", + Version: "1.0.0", + ImageTag: "ghcr.io/org/test-agent:1.0.0", + Builder: "docker", + Platform: "linux/amd64", + BuiltAt: "2025-01-01T00:00:00Z", + BuildDir: "/tmp/build", + Pushed: true, + } + + if err := WriteManifest(path, original); err != nil { + t.Fatalf("WriteManifest() error: %v", err) + } + + // Verify file exists + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Fatal("manifest file not created") + } + + // Read back + got, err := ReadManifest(path) + if err != nil { + t.Fatalf("ReadManifest() error: %v", err) + } + + if got.AgentID != original.AgentID { + t.Errorf("AgentID = %q, want %q", got.AgentID, original.AgentID) + } + if got.Version != original.Version { + t.Errorf("Version = %q, want %q", got.Version, original.Version) + } + if got.ImageTag != original.ImageTag { + t.Errorf("ImageTag = %q, want %q", got.ImageTag, original.ImageTag) + } + if got.Builder != original.Builder { + t.Errorf("Builder = %q, want %q", got.Builder, original.Builder) + } + if got.Platform != original.Platform { + t.Errorf("Platform = %q, want %q", got.Platform, original.Platform) + } + if got.BuiltAt != original.BuiltAt { + t.Errorf("BuiltAt = %q, want %q", got.BuiltAt, original.BuiltAt) + } + if got.BuildDir != original.BuildDir { + t.Errorf("BuildDir = %q, want %q", got.BuildDir, original.BuildDir) + } + if got.Pushed != original.Pushed { + t.Errorf("Pushed = %v, want %v", got.Pushed, original.Pushed) + } +} + +func TestReadManifest_NotFound(t *testing.T) { + _, err := ReadManifest("/nonexistent/path/manifest.json") + if err == nil { + t.Error("expected error for nonexistent manifest") + } +} + +func TestWriteManifest_InvalidPath(t *testing.T) { + m := &ImageManifest{AgentID: "test"} + err := WriteManifest("/nonexistent/dir/manifest.json", m) + if err == nil { + t.Error("expected error for invalid path") + } +} diff --git a/forge-cli/container/podman.go b/forge-cli/container/podman.go new file mode 100644 index 0000000..d2126c6 --- /dev/null +++ b/forge-cli/container/podman.go @@ -0,0 +1,76 @@ +package container + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" +) + +// PodmanBuilder builds container images using the podman CLI. +type PodmanBuilder struct{} + +func (b *PodmanBuilder) Name() string { return "podman" } + +func (b *PodmanBuilder) Available() bool { + return exec.Command("podman", "info").Run() == nil +} + +func (b *PodmanBuilder) Build(ctx context.Context, opts BuildOptions) (*BuildResult, error) { + args := []string{"build"} + + if opts.Tag != "" { + args = append(args, "-t", opts.Tag) + } + if opts.Dockerfile != "" { + args = append(args, "-f", opts.Dockerfile) + } + if opts.Platform != "" { + args = append(args, "--platform", opts.Platform) + } + if opts.NoCache { + args = append(args, "--no-cache") + } + for k, v := range opts.BuildArgs { + args = append(args, "--build-arg", fmt.Sprintf("%s=%s", k, v)) + } + + contextDir := opts.ContextDir + if contextDir == "" { + contextDir = "." + } + args = append(args, contextDir) + + cmd := exec.CommandContext(ctx, "podman", args...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("podman build failed: %s: %w", stderr.String(), err) + } + + // Podman outputs the image ID on the last line + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + imageID := "" + if len(lines) > 0 { + imageID = strings.TrimSpace(lines[len(lines)-1]) + } + + return &BuildResult{ + ImageID: imageID, + Tag: opts.Tag, + }, nil +} + +func (b *PodmanBuilder) Push(ctx context.Context, image string) error { + cmd := exec.CommandContext(ctx, "podman", "push", image) + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("podman push failed: %s: %w", stderr.String(), err) + } + return nil +} diff --git a/forge-cli/container/verify.go b/forge-cli/container/verify.go new file mode 100644 index 0000000..b0ea012 --- /dev/null +++ b/forge-cli/container/verify.go @@ -0,0 +1,94 @@ +package container + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "os/exec" + "strings" + "time" +) + +// Verify performs a smoke test on a built container image. +// It starts the container, waits for startup, checks /healthz and /.well-known/agent.json, +// then stops and removes the container. +func Verify(ctx context.Context, imageTag string) error { + // Find a free port + port, err := freePort() + if err != nil { + return fmt.Errorf("finding free port: %w", err) + } + + containerName := fmt.Sprintf("forge-verify-%d", port) + + // Start container + runArgs := []string{ + "run", "-d", + "--name", containerName, + "-p", fmt.Sprintf("%d:8080", port), + imageTag, + } + + out, err := exec.CommandContext(ctx, "docker", runArgs...).CombinedOutput() + if err != nil { + return fmt.Errorf("starting container: %s: %w", strings.TrimSpace(string(out)), err) + } + + // Ensure cleanup + defer func() { + exec.Command("docker", "stop", containerName).Run() //nolint:errcheck + exec.Command("docker", "rm", "-f", containerName).Run() //nolint:errcheck + }() + + // Wait for startup + baseURL := fmt.Sprintf("http://localhost:%d", port) + client := &http.Client{Timeout: 5 * time.Second} + + if err := waitForHealthy(ctx, client, baseURL+"/healthz", 30*time.Second); err != nil { + return fmt.Errorf("health check failed: %w", err) + } + + // Check /.well-known/agent.json + resp, err := client.Get(baseURL + "/.well-known/agent.json") + if err != nil { + return fmt.Errorf("fetching agent.json: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("agent.json returned status %d: %s", resp.StatusCode, string(body)) + } + + return nil +} + +func freePort() (int, error) { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return 0, err + } + port := l.Addr().(*net.TCPAddr).Port + _ = l.Close() + return port, nil +} + +func waitForHealthy(ctx context.Context, client *http.Client, url string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if ctx.Err() != nil { + return ctx.Err() + } + resp, err := client.Get(url) + if err == nil { + _ = resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return nil + } + } + time.Sleep(1 * time.Second) + } + return fmt.Errorf("timeout waiting for %s", url) +} diff --git a/forge-cli/go.mod b/forge-cli/go.mod new file mode 100644 index 0000000..40cb49b --- /dev/null +++ b/forge-cli/go.mod @@ -0,0 +1,26 @@ +module github.com/initializ/forge/forge-cli + +go 1.25.0 + +require ( + github.com/initializ/forge/forge-core v0.0.0 + github.com/initializ/forge/forge-plugins v0.0.0 + github.com/manifoldco/promptui v0.9.0 + github.com/spf13/cobra v1.10.2 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/xeipuuv/gojsonschema v1.2.0 // indirect + golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b // indirect +) + +replace ( + github.com/initializ/forge/forge-core => ../forge-core + github.com/initializ/forge/forge-plugins => ../forge-plugins +) diff --git a/forge-cli/go.sum b/forge-cli/go.sum new file mode 100644 index 0000000..d1ac988 --- /dev/null +++ b/forge-cli/go.sum @@ -0,0 +1,36 @@ +github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= +github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b h1:MQE+LT/ABUuuvEZ+YQAMSXindAdUh7slEmAkup74op4= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/forge-cli/plugins/crewai/crewai.go b/forge-cli/plugins/crewai/crewai.go new file mode 100644 index 0000000..7cfa9da --- /dev/null +++ b/forge-cli/plugins/crewai/crewai.go @@ -0,0 +1,160 @@ +// Package crewai provides a framework plugin for CrewAI agent projects. +package crewai + +import ( + "bytes" + "os" + "path/filepath" + "regexp" + "strings" + "text/template" + + "github.com/initializ/forge/forge-cli/templates" + "github.com/initializ/forge/forge-core/plugins" +) + +// Plugin is the CrewAI framework plugin. +type Plugin struct{} + +func (p *Plugin) Name() string { return "crewai" } + +// DetectProject checks for CrewAI markers in the project directory. +func (p *Plugin) DetectProject(dir string) (bool, error) { + // Check requirements.txt + if found, err := fileContains(filepath.Join(dir, "requirements.txt"), "crewai"); err == nil && found { + return true, nil + } + + // Check pyproject.toml + if found, err := fileContains(filepath.Join(dir, "pyproject.toml"), "crewai"); err == nil && found { + return true, nil + } + + // Scan top-level .py files for crewai imports + entries, err := os.ReadDir(dir) + if err != nil { + return false, nil + } + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".py") { + continue + } + data, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + continue + } + content := string(data) + if strings.Contains(content, "from crewai import") || strings.Contains(content, "import crewai") { + return true, nil + } + } + + return false, nil +} + +var ( + reAgentRole = regexp.MustCompile(`Agent\(\s*role\s*=\s*"([^"]+)"`) + reAgentGoal = regexp.MustCompile(`goal\s*=\s*"([^"]+)"`) + reToolClass = regexp.MustCompile(`class\s+(\w+)\(BaseTool\)`) + reToolName = regexp.MustCompile(`name:\s*str\s*=\s*"([^"]+)"`) + reToolDesc = regexp.MustCompile(`description:\s*str\s*=\s*"([^"]+)"`) + reAgentBackstory = regexp.MustCompile(`backstory\s*=\s*"([^"]+)"`) +) + +// ExtractAgentConfig scans Python files for CrewAI patterns. +func (p *Plugin) ExtractAgentConfig(dir string) (*plugins.AgentConfig, error) { + cfg := &plugins.AgentConfig{} + + entries, err := os.ReadDir(dir) + if err != nil { + return cfg, nil + } + + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".py") { + continue + } + data, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + continue + } + content := string(data) + + // Extract agent identity + if m := reAgentRole.FindStringSubmatch(content); m != nil { + if cfg.Identity == nil { + cfg.Identity = &plugins.IdentityConfig{} + } + cfg.Identity.Role = m[1] + } + if m := reAgentGoal.FindStringSubmatch(content); m != nil { + if cfg.Identity == nil { + cfg.Identity = &plugins.IdentityConfig{} + } + cfg.Identity.Goal = m[1] + } + if m := reAgentBackstory.FindStringSubmatch(content); m != nil { + if cfg.Identity == nil { + cfg.Identity = &plugins.IdentityConfig{} + } + cfg.Identity.Backstory = m[1] + } + + // Extract tool classes + classMatches := reToolClass.FindAllStringSubmatch(content, -1) + for _, cm := range classMatches { + tool := plugins.ToolDefinition{Name: cm[1]} + + // Try to find name and description fields near the class + if m := reToolName.FindStringSubmatch(content); m != nil { + tool.Name = m[1] + } + if m := reToolDesc.FindStringSubmatch(content); m != nil { + tool.Description = m[1] + } + + cfg.Tools = append(cfg.Tools, tool) + } + } + + // Set description from identity if available + if cfg.Identity != nil && cfg.Identity.Goal != "" { + cfg.Description = cfg.Identity.Goal + } + + return cfg, nil +} + +// GenerateWrapper renders the CrewAI A2A wrapper template. +func (p *Plugin) GenerateWrapper(config *plugins.AgentConfig) ([]byte, error) { + tmplStr, err := templates.GetWrapperTemplate("crewai_wrapper.py.tmpl") + if err != nil { + return nil, err + } + + tmpl, err := template.New("crewai_wrapper").Parse(tmplStr) + if err != nil { + return nil, err + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, config); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +// RuntimeDependencies returns CrewAI pip packages. +func (p *Plugin) RuntimeDependencies() []string { + return []string{"crewai", "crewai-tools"} +} + +// fileContains checks if a file exists and contains the given substring. +func fileContains(path, substr string) (bool, error) { + data, err := os.ReadFile(path) + if err != nil { + return false, err + } + return strings.Contains(string(data), substr), nil +} diff --git a/forge-cli/plugins/crewai/crewai_test.go b/forge-cli/plugins/crewai/crewai_test.go new file mode 100644 index 0000000..7eeae6e --- /dev/null +++ b/forge-cli/plugins/crewai/crewai_test.go @@ -0,0 +1,167 @@ +package crewai + +import ( + "os" + "path/filepath" + "testing" +) + +func TestPlugin_Name(t *testing.T) { + p := &Plugin{} + if p.Name() != "crewai" { + t.Errorf("Name() = %q, want crewai", p.Name()) + } +} + +func TestPlugin_DetectProject_RequirementsTxt(t *testing.T) { + dir := t.TempDir() + _ = os.WriteFile(filepath.Join(dir, "requirements.txt"), []byte("crewai>=0.30\ncrewai-tools\n"), 0644) + + p := &Plugin{} + ok, err := p.DetectProject(dir) + if err != nil { + t.Fatalf("DetectProject error: %v", err) + } + if !ok { + t.Error("expected DetectProject to return true for requirements.txt with crewai") + } +} + +func TestPlugin_DetectProject_PyprojectToml(t *testing.T) { + dir := t.TempDir() + _ = os.WriteFile(filepath.Join(dir, "pyproject.toml"), []byte(`[tool.poetry.dependencies] +crewai = "^0.30" +`), 0644) + + p := &Plugin{} + ok, err := p.DetectProject(dir) + if err != nil { + t.Fatalf("DetectProject error: %v", err) + } + if !ok { + t.Error("expected DetectProject to return true for pyproject.toml with crewai") + } +} + +func TestPlugin_DetectProject_PythonImport(t *testing.T) { + dir := t.TempDir() + _ = os.WriteFile(filepath.Join(dir, "agent.py"), []byte(`from crewai import Agent, Task, Crew +agent = Agent(role="researcher") +`), 0644) + + p := &Plugin{} + ok, err := p.DetectProject(dir) + if err != nil { + t.Fatalf("DetectProject error: %v", err) + } + if !ok { + t.Error("expected DetectProject to return true for .py with crewai import") + } +} + +func TestPlugin_DetectProject_NoMarkers(t *testing.T) { + dir := t.TempDir() + _ = os.WriteFile(filepath.Join(dir, "agent.py"), []byte("print('hello')\n"), 0644) + + p := &Plugin{} + ok, err := p.DetectProject(dir) + if err != nil { + t.Fatalf("DetectProject error: %v", err) + } + if ok { + t.Error("expected DetectProject to return false without crewai markers") + } +} + +func TestPlugin_DetectProject_EmptyDir(t *testing.T) { + dir := t.TempDir() + + p := &Plugin{} + ok, err := p.DetectProject(dir) + if err != nil { + t.Fatalf("DetectProject error: %v", err) + } + if ok { + t.Error("expected DetectProject to return false for empty dir") + } +} + +func TestPlugin_ExtractAgentConfig_FullPattern(t *testing.T) { + dir := t.TempDir() + _ = os.WriteFile(filepath.Join(dir, "agent.py"), []byte(` +from crewai import Agent, Task, Crew +from crewai_tools import BaseTool + +class WebSearchTool(BaseTool): + name: str = "web_search" + description: str = "Search the web for information" + + def _run(self, query: str) -> str: + return "results" + +agent = Agent( + role="Research Analyst", + goal="Find accurate information", + backstory="Expert researcher with years of experience" +) +`), 0644) + + p := &Plugin{} + cfg, err := p.ExtractAgentConfig(dir) + if err != nil { + t.Fatalf("ExtractAgentConfig error: %v", err) + } + + if cfg.Identity == nil { + t.Fatal("expected Identity to be set") + } + if cfg.Identity.Role != "Research Analyst" { + t.Errorf("Role = %q, want 'Research Analyst'", cfg.Identity.Role) + } + if cfg.Identity.Goal != "Find accurate information" { + t.Errorf("Goal = %q, want 'Find accurate information'", cfg.Identity.Goal) + } + if cfg.Identity.Backstory != "Expert researcher with years of experience" { + t.Errorf("Backstory = %q", cfg.Identity.Backstory) + } + + if len(cfg.Tools) != 1 { + t.Fatalf("expected 1 tool, got %d", len(cfg.Tools)) + } + if cfg.Tools[0].Name != "web_search" { + t.Errorf("Tool.Name = %q, want web_search", cfg.Tools[0].Name) + } + if cfg.Tools[0].Description != "Search the web for information" { + t.Errorf("Tool.Description = %q", cfg.Tools[0].Description) + } + + if cfg.Description != "Find accurate information" { + t.Errorf("Description = %q, want 'Find accurate information'", cfg.Description) + } +} + +func TestPlugin_ExtractAgentConfig_NoPythonFiles(t *testing.T) { + dir := t.TempDir() + p := &Plugin{} + cfg, err := p.ExtractAgentConfig(dir) + if err != nil { + t.Fatalf("ExtractAgentConfig error: %v", err) + } + if cfg.Identity != nil { + t.Error("expected nil Identity for empty dir") + } + if len(cfg.Tools) != 0 { + t.Error("expected no tools for empty dir") + } +} + +func TestPlugin_RuntimeDependencies(t *testing.T) { + p := &Plugin{} + deps := p.RuntimeDependencies() + if len(deps) != 2 { + t.Fatalf("expected 2 deps, got %d", len(deps)) + } + if deps[0] != "crewai" || deps[1] != "crewai-tools" { + t.Errorf("deps = %v", deps) + } +} diff --git a/forge-cli/plugins/custom/custom.go b/forge-cli/plugins/custom/custom.go new file mode 100644 index 0000000..406a2e6 --- /dev/null +++ b/forge-cli/plugins/custom/custom.go @@ -0,0 +1,25 @@ +// Package custom provides the fallback framework plugin for custom agent projects. +package custom + +import "github.com/initializ/forge/forge-core/plugins" + +// Plugin is the custom/fallback framework plugin. +type Plugin struct{} + +func (p *Plugin) Name() string { return "custom" } + +// DetectProject always returns true -- custom is the fallback. +func (p *Plugin) DetectProject(dir string) (bool, error) { return true, nil } + +// ExtractAgentConfig returns an empty config -- forge.yaml is the authority for custom projects. +func (p *Plugin) ExtractAgentConfig(dir string) (*plugins.AgentConfig, error) { + return &plugins.AgentConfig{}, nil +} + +// GenerateWrapper returns nil -- custom projects already include their own A2A server. +func (p *Plugin) GenerateWrapper(config *plugins.AgentConfig) ([]byte, error) { + return nil, nil +} + +// RuntimeDependencies returns nil -- no framework-specific dependencies. +func (p *Plugin) RuntimeDependencies() []string { return nil } diff --git a/forge-cli/plugins/custom/custom_test.go b/forge-cli/plugins/custom/custom_test.go new file mode 100644 index 0000000..5e7e7f4 --- /dev/null +++ b/forge-cli/plugins/custom/custom_test.go @@ -0,0 +1,54 @@ +package custom + +import "testing" + +func TestPlugin_Name(t *testing.T) { + p := &Plugin{} + if p.Name() != "custom" { + t.Errorf("Name() = %q, want custom", p.Name()) + } +} + +func TestPlugin_DetectProject_AlwaysTrue(t *testing.T) { + p := &Plugin{} + ok, err := p.DetectProject(t.TempDir()) + if err != nil { + t.Fatalf("DetectProject error: %v", err) + } + if !ok { + t.Error("DetectProject should always return true") + } +} + +func TestPlugin_ExtractAgentConfig_Empty(t *testing.T) { + p := &Plugin{} + cfg, err := p.ExtractAgentConfig(t.TempDir()) + if err != nil { + t.Fatalf("ExtractAgentConfig error: %v", err) + } + if cfg == nil { + t.Fatal("expected non-nil AgentConfig") + } + if cfg.Name != "" { + t.Errorf("expected empty Name, got %q", cfg.Name) + } +} + +func TestPlugin_GenerateWrapper_Nil(t *testing.T) { + p := &Plugin{} + data, err := p.GenerateWrapper(nil) + if err != nil { + t.Fatalf("GenerateWrapper error: %v", err) + } + if data != nil { + t.Error("expected nil wrapper for custom plugin") + } +} + +func TestPlugin_RuntimeDependencies_Nil(t *testing.T) { + p := &Plugin{} + deps := p.RuntimeDependencies() + if deps != nil { + t.Errorf("expected nil dependencies, got %v", deps) + } +} diff --git a/forge-cli/plugins/langchain/langchain.go b/forge-cli/plugins/langchain/langchain.go new file mode 100644 index 0000000..7e77773 --- /dev/null +++ b/forge-cli/plugins/langchain/langchain.go @@ -0,0 +1,135 @@ +// Package langchain provides a framework plugin for LangChain agent projects. +package langchain + +import ( + "bytes" + "os" + "path/filepath" + "regexp" + "strings" + "text/template" + + "github.com/initializ/forge/forge-cli/templates" + "github.com/initializ/forge/forge-core/plugins" +) + +// Plugin is the LangChain framework plugin. +type Plugin struct{} + +func (p *Plugin) Name() string { return "langchain" } + +// DetectProject checks for LangChain markers in the project directory. +func (p *Plugin) DetectProject(dir string) (bool, error) { + // Check requirements.txt + if found, err := fileContains(filepath.Join(dir, "requirements.txt"), "langchain"); err == nil && found { + return true, nil + } + + // Check pyproject.toml + if found, err := fileContains(filepath.Join(dir, "pyproject.toml"), "langchain"); err == nil && found { + return true, nil + } + + // Scan top-level .py files for langchain imports + entries, err := os.ReadDir(dir) + if err != nil { + return false, nil + } + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".py") { + continue + } + data, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + continue + } + content := string(data) + if strings.Contains(content, "from langchain") || strings.Contains(content, "import langchain") { + return true, nil + } + } + + return false, nil +} + +var ( + reToolDef = regexp.MustCompile(`@tool\s*\n\s*def\s+(\w+)\(`) + reToolDoc = regexp.MustCompile(`@tool\s*\n\s*def\s+\w+\([^)]*\)[^:]*:\s*\n\s*"""([^"]+)"""`) + reModelName = regexp.MustCompile(`Chat(?:OpenAI|Anthropic)\(\s*model\s*=\s*"([^"]+)"`) +) + +// ExtractAgentConfig scans Python files for LangChain patterns. +func (p *Plugin) ExtractAgentConfig(dir string) (*plugins.AgentConfig, error) { + cfg := &plugins.AgentConfig{} + + entries, err := os.ReadDir(dir) + if err != nil { + return cfg, nil + } + + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".py") { + continue + } + data, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + continue + } + content := string(data) + + // Extract @tool decorated functions + toolMatches := reToolDef.FindAllStringSubmatch(content, -1) + for _, m := range toolMatches { + tool := plugins.ToolDefinition{Name: m[1]} + cfg.Tools = append(cfg.Tools, tool) + } + + // Extract tool docstrings + docMatches := reToolDoc.FindAllStringSubmatch(content, -1) + for i, m := range docMatches { + if i < len(cfg.Tools) { + cfg.Tools[i].Description = strings.TrimSpace(m[1]) + } + } + + // Extract model name + if m := reModelName.FindStringSubmatch(content); m != nil { + cfg.Model = &plugins.PluginModelConfig{Name: m[1]} + } + } + + return cfg, nil +} + +// GenerateWrapper renders the LangChain A2A wrapper template. +func (p *Plugin) GenerateWrapper(config *plugins.AgentConfig) ([]byte, error) { + tmplStr, err := templates.GetWrapperTemplate("langchain_wrapper.py.tmpl") + if err != nil { + return nil, err + } + + tmpl, err := template.New("langchain_wrapper").Parse(tmplStr) + if err != nil { + return nil, err + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, config); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +// RuntimeDependencies returns LangChain pip packages. +func (p *Plugin) RuntimeDependencies() []string { + return []string{"langchain", "langchain-core", "langchain-openai"} +} + +func fileContains(path, substr string) (bool, error) { + data, err := os.ReadFile(path) + if err != nil { + return false, err + } + return strings.Contains(string(data), substr), nil +} diff --git a/forge-cli/plugins/langchain/langchain_test.go b/forge-cli/plugins/langchain/langchain_test.go new file mode 100644 index 0000000..006c22f --- /dev/null +++ b/forge-cli/plugins/langchain/langchain_test.go @@ -0,0 +1,149 @@ +package langchain + +import ( + "os" + "path/filepath" + "testing" +) + +func TestPlugin_Name(t *testing.T) { + p := &Plugin{} + if p.Name() != "langchain" { + t.Errorf("Name() = %q, want langchain", p.Name()) + } +} + +func TestPlugin_DetectProject_RequirementsTxt(t *testing.T) { + dir := t.TempDir() + _ = os.WriteFile(filepath.Join(dir, "requirements.txt"), []byte("langchain>=0.1\nlangchain-openai\n"), 0644) + + p := &Plugin{} + ok, err := p.DetectProject(dir) + if err != nil { + t.Fatalf("DetectProject error: %v", err) + } + if !ok { + t.Error("expected DetectProject to return true") + } +} + +func TestPlugin_DetectProject_PyprojectToml(t *testing.T) { + dir := t.TempDir() + _ = os.WriteFile(filepath.Join(dir, "pyproject.toml"), []byte(`[tool.poetry.dependencies] +langchain = "^0.1" +`), 0644) + + p := &Plugin{} + ok, err := p.DetectProject(dir) + if err != nil { + t.Fatalf("DetectProject error: %v", err) + } + if !ok { + t.Error("expected DetectProject to return true") + } +} + +func TestPlugin_DetectProject_PythonImport(t *testing.T) { + dir := t.TempDir() + _ = os.WriteFile(filepath.Join(dir, "agent.py"), []byte(`from langchain.agents import AgentExecutor +from langchain_openai import ChatOpenAI +`), 0644) + + p := &Plugin{} + ok, err := p.DetectProject(dir) + if err != nil { + t.Fatalf("DetectProject error: %v", err) + } + if !ok { + t.Error("expected DetectProject to return true") + } +} + +func TestPlugin_DetectProject_NoMarkers(t *testing.T) { + dir := t.TempDir() + _ = os.WriteFile(filepath.Join(dir, "agent.py"), []byte("print('hello')\n"), 0644) + + p := &Plugin{} + ok, err := p.DetectProject(dir) + if err != nil { + t.Fatalf("DetectProject error: %v", err) + } + if ok { + t.Error("expected DetectProject to return false") + } +} + +func TestPlugin_ExtractAgentConfig_ToolDecorators(t *testing.T) { + dir := t.TempDir() + _ = os.WriteFile(filepath.Join(dir, "tools.py"), []byte(`from langchain.tools import tool + +@tool +def web_search(query: str) -> str: + """Search the web for information""" + return "results" + +@tool +def calculator(expression: str) -> str: + """Calculate mathematical expressions""" + return str(eval(expression)) +`), 0644) + + p := &Plugin{} + cfg, err := p.ExtractAgentConfig(dir) + if err != nil { + t.Fatalf("ExtractAgentConfig error: %v", err) + } + + if len(cfg.Tools) != 2 { + t.Fatalf("expected 2 tools, got %d", len(cfg.Tools)) + } + if cfg.Tools[0].Name != "web_search" { + t.Errorf("Tool[0].Name = %q, want web_search", cfg.Tools[0].Name) + } + if cfg.Tools[1].Name != "calculator" { + t.Errorf("Tool[1].Name = %q, want calculator", cfg.Tools[1].Name) + } +} + +func TestPlugin_ExtractAgentConfig_ModelExtraction(t *testing.T) { + dir := t.TempDir() + _ = os.WriteFile(filepath.Join(dir, "agent.py"), []byte(`from langchain_openai import ChatOpenAI +llm = ChatOpenAI(model="gpt-4-turbo") +`), 0644) + + p := &Plugin{} + cfg, err := p.ExtractAgentConfig(dir) + if err != nil { + t.Fatalf("ExtractAgentConfig error: %v", err) + } + + if cfg.Model == nil { + t.Fatal("expected Model to be set") + } + if cfg.Model.Name != "gpt-4-turbo" { + t.Errorf("Model.Name = %q, want gpt-4-turbo", cfg.Model.Name) + } +} + +func TestPlugin_ExtractAgentConfig_NoPythonFiles(t *testing.T) { + dir := t.TempDir() + p := &Plugin{} + cfg, err := p.ExtractAgentConfig(dir) + if err != nil { + t.Fatalf("ExtractAgentConfig error: %v", err) + } + if len(cfg.Tools) != 0 { + t.Error("expected no tools for empty dir") + } +} + +func TestPlugin_RuntimeDependencies(t *testing.T) { + p := &Plugin{} + deps := p.RuntimeDependencies() + if len(deps) != 3 { + t.Fatalf("expected 3 deps, got %d", len(deps)) + } + if deps[0] != "langchain" { + t.Errorf("deps[0] = %q, want langchain", deps[0]) + } +} diff --git a/forge-cli/runtime/agentcard.go b/forge-cli/runtime/agentcard.go new file mode 100644 index 0000000..b12e4e5 --- /dev/null +++ b/forge-cli/runtime/agentcard.go @@ -0,0 +1,44 @@ +package runtime + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/initializ/forge/forge-core/a2a" + "github.com/initializ/forge/forge-core/agentspec" + coreruntime "github.com/initializ/forge/forge-core/runtime" + "github.com/initializ/forge/forge-core/types" +) + +// BuildAgentCard constructs an AgentCard from available sources. +// It first tries .forge-output/agent.json; if that doesn't exist, it falls +// back to the ForgeConfig. +func BuildAgentCard(workDir string, cfg *types.ForgeConfig, port int) (*a2a.AgentCard, error) { + baseURL := fmt.Sprintf("http://localhost:%d", port) + + // Try loading from a prior build + card, err := agentCardFromDisk(workDir, baseURL) + if err == nil && card != nil { + return card, nil + } + + // Fall back to forge.yaml config + return coreruntime.AgentCardFromConfig(cfg, baseURL), nil +} + +func agentCardFromDisk(workDir string, baseURL string) (*a2a.AgentCard, error) { + path := filepath.Join(workDir, ".forge-output", "agent.json") + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var spec agentspec.AgentSpec + if err := json.Unmarshal(data, &spec); err != nil { + return nil, fmt.Errorf("parsing agent.json: %w", err) + } + + return coreruntime.AgentCardFromSpec(&spec, baseURL), nil +} diff --git a/forge-cli/runtime/env.go b/forge-cli/runtime/env.go new file mode 100644 index 0000000..61284f7 --- /dev/null +++ b/forge-cli/runtime/env.go @@ -0,0 +1,21 @@ +package runtime + +import ( + "os" + + coreruntime "github.com/initializ/forge/forge-core/runtime" +) + +// LoadEnvFile reads a .env file and returns key-value pairs. +// Missing files return an empty map and no error. +func LoadEnvFile(path string) (map[string]string, error) { + f, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return map[string]string{}, nil + } + return nil, err + } + defer func() { _ = f.Close() }() + return coreruntime.ParseEnvVars(f) +} diff --git a/forge-cli/runtime/guardrails_loader.go b/forge-cli/runtime/guardrails_loader.go new file mode 100644 index 0000000..caab748 --- /dev/null +++ b/forge-cli/runtime/guardrails_loader.go @@ -0,0 +1,28 @@ +package runtime + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/initializ/forge/forge-core/agentspec" +) + +// LoadPolicyScaffold reads policy-scaffold.json from the output directory. +// Returns nil (no error) if the file does not exist. +func LoadPolicyScaffold(workDir string) (*agentspec.PolicyScaffold, error) { + path := filepath.Join(workDir, ".forge-output", "policy-scaffold.json") + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var ps agentspec.PolicyScaffold + if err := json.Unmarshal(data, &ps); err != nil { + return nil, fmt.Errorf("parsing policy scaffold: %w", err) + } + return &ps, nil +} diff --git a/forge-cli/runtime/mock.go b/forge-cli/runtime/mock.go new file mode 100644 index 0000000..cb67dd7 --- /dev/null +++ b/forge-cli/runtime/mock.go @@ -0,0 +1,85 @@ +package runtime + +import ( + "context" + "fmt" + "strings" + + "github.com/initializ/forge/forge-core/a2a" + "github.com/initializ/forge/forge-core/agentspec" +) + +// MockRuntime implements AgentRuntime without a real subprocess. It returns +// canned responses based on the agent's tool specs. Useful for testing the +// A2A protocol layer without needing Python or other frameworks installed. +type MockRuntime struct { + tools []agentspec.ToolSpec +} + +// NewMockRuntime creates a MockRuntime with the given tool specs. +func NewMockRuntime(tools []agentspec.ToolSpec) *MockRuntime { + return &MockRuntime{tools: tools} +} + +func (m *MockRuntime) Start(ctx context.Context) error { return nil } +func (m *MockRuntime) Stop() error { return nil } +func (m *MockRuntime) Restart(ctx context.Context) error { return nil } +func (m *MockRuntime) Healthy(ctx context.Context) bool { return true } + +// Invoke returns a completed task with mock text content. +func (m *MockRuntime) Invoke(ctx context.Context, taskID string, msg *a2a.Message) (*a2a.Task, error) { + input := extractInputText(msg) + responseText := fmt.Sprintf("Mock response for: %s", input) + + if len(m.tools) > 0 { + var names []string + for _, t := range m.tools { + names = append(names, t.Name) + } + responseText += fmt.Sprintf(" (available tools: %s)", strings.Join(names, ", ")) + } + + task := &a2a.Task{ + ID: taskID, + Status: a2a.TaskStatus{ + State: a2a.TaskStateCompleted, + Message: &a2a.Message{ + Role: a2a.MessageRoleAgent, + Parts: []a2a.Part{a2a.NewTextPart(responseText)}, + }, + }, + Artifacts: []a2a.Artifact{ + { + Name: "response", + Parts: []a2a.Part{a2a.NewTextPart(responseText)}, + }, + }, + } + return task, nil +} + +// Stream wraps Invoke as a single-item channel. +func (m *MockRuntime) Stream(ctx context.Context, taskID string, msg *a2a.Message) (<-chan *a2a.Task, error) { + ch := make(chan *a2a.Task, 1) + task, err := m.Invoke(ctx, taskID, msg) + if err != nil { + close(ch) + return ch, err + } + ch <- task + close(ch) + return ch, nil +} + +func extractInputText(msg *a2a.Message) string { + var parts []string + for _, p := range msg.Parts { + if p.Kind == a2a.PartKindText && p.Text != "" { + parts = append(parts, p.Text) + } + } + if len(parts) == 0 { + return "" + } + return strings.Join(parts, " ") +} diff --git a/forge-cli/runtime/mock_executor.go b/forge-cli/runtime/mock_executor.go new file mode 100644 index 0000000..d7d3547 --- /dev/null +++ b/forge-cli/runtime/mock_executor.go @@ -0,0 +1,56 @@ +package runtime + +import ( + "context" + "fmt" + "strings" + + "github.com/initializ/forge/forge-core/a2a" + "github.com/initializ/forge/forge-core/agentspec" +) + +// MockExecutor implements AgentExecutor with canned responses. +// It produces the same output format as MockRuntime for backward compatibility. +type MockExecutor struct { + tools []agentspec.ToolSpec +} + +// NewMockExecutor creates a MockExecutor with the given tool specs. +func NewMockExecutor(tools []agentspec.ToolSpec) *MockExecutor { + return &MockExecutor{tools: tools} +} + +// Execute returns a message with mock text content. +func (m *MockExecutor) Execute(ctx context.Context, task *a2a.Task, msg *a2a.Message) (*a2a.Message, error) { + input := extractInputText(msg) + responseText := fmt.Sprintf("Mock response for: %s", input) + + if len(m.tools) > 0 { + var names []string + for _, t := range m.tools { + names = append(names, t.Name) + } + responseText += fmt.Sprintf(" (available tools: %s)", strings.Join(names, ", ")) + } + + return &a2a.Message{ + Role: a2a.MessageRoleAgent, + Parts: []a2a.Part{a2a.NewTextPart(responseText)}, + }, nil +} + +// ExecuteStream wraps Execute as a single-item channel. +func (m *MockExecutor) ExecuteStream(ctx context.Context, task *a2a.Task, msg *a2a.Message) (<-chan *a2a.Message, error) { + ch := make(chan *a2a.Message, 1) + resp, err := m.Execute(ctx, task, msg) + if err != nil { + close(ch) + return ch, err + } + ch <- resp + close(ch) + return ch, nil +} + +// Close is a no-op for MockExecutor. +func (m *MockExecutor) Close() error { return nil } diff --git a/forge-cli/runtime/mock_executor_test.go b/forge-cli/runtime/mock_executor_test.go new file mode 100644 index 0000000..47dcc4b --- /dev/null +++ b/forge-cli/runtime/mock_executor_test.go @@ -0,0 +1,95 @@ +package runtime + +import ( + "context" + "strings" + "testing" + + "github.com/initializ/forge/forge-core/a2a" + "github.com/initializ/forge/forge-core/agentspec" +) + +func TestMockExecutor_Execute(t *testing.T) { + tools := []agentspec.ToolSpec{ + {Name: "search", Description: "Search the web"}, + {Name: "calculator", Description: "Do math"}, + } + + exec := NewMockExecutor(tools) + task := &a2a.Task{ID: "t-1"} + msg := &a2a.Message{ + Role: a2a.MessageRoleUser, + Parts: []a2a.Part{a2a.NewTextPart("hello world")}, + } + + resp, err := exec.Execute(context.Background(), task, msg) + if err != nil { + t.Fatalf("Execute error: %v", err) + } + + if resp.Role != a2a.MessageRoleAgent { + t.Errorf("role: got %q, want %q", resp.Role, a2a.MessageRoleAgent) + } + + text := resp.Parts[0].Text + if !strings.Contains(text, "hello world") { + t.Errorf("response should contain input, got: %q", text) + } + if !strings.Contains(text, "search") { + t.Errorf("response should mention tools, got: %q", text) + } + if !strings.Contains(text, "calculator") { + t.Errorf("response should mention all tools, got: %q", text) + } +} + +func TestMockExecutor_ExecuteNoTools(t *testing.T) { + exec := NewMockExecutor(nil) + task := &a2a.Task{ID: "t-2"} + msg := &a2a.Message{ + Role: a2a.MessageRoleUser, + Parts: []a2a.Part{a2a.NewTextPart("test")}, + } + + resp, err := exec.Execute(context.Background(), task, msg) + if err != nil { + t.Fatalf("Execute error: %v", err) + } + + text := resp.Parts[0].Text + if strings.Contains(text, "available tools") { + t.Errorf("should not mention tools when none, got: %q", text) + } +} + +func TestMockExecutor_ExecuteStream(t *testing.T) { + exec := NewMockExecutor(nil) + task := &a2a.Task{ID: "t-3"} + msg := &a2a.Message{ + Role: a2a.MessageRoleUser, + Parts: []a2a.Part{a2a.NewTextPart("test")}, + } + + ch, err := exec.ExecuteStream(context.Background(), task, msg) + if err != nil { + t.Fatalf("ExecuteStream error: %v", err) + } + + count := 0 + for resp := range ch { + count++ + if resp.Role != a2a.MessageRoleAgent { + t.Errorf("role: got %q", resp.Role) + } + } + if count != 1 { + t.Errorf("expected 1 message, got %d", count) + } +} + +func TestMockExecutor_Close(t *testing.T) { + exec := NewMockExecutor(nil) + if err := exec.Close(); err != nil { + t.Errorf("Close: %v", err) + } +} diff --git a/forge-cli/runtime/mock_test.go b/forge-cli/runtime/mock_test.go new file mode 100644 index 0000000..9e0df35 --- /dev/null +++ b/forge-cli/runtime/mock_test.go @@ -0,0 +1,109 @@ +package runtime + +import ( + "context" + "strings" + "testing" + + "github.com/initializ/forge/forge-core/a2a" + "github.com/initializ/forge/forge-core/agentspec" +) + +func TestMockRuntime_Invoke(t *testing.T) { + tools := []agentspec.ToolSpec{ + {Name: "search", Description: "Search the web"}, + {Name: "calculator", Description: "Do math"}, + } + + rt := NewMockRuntime(tools) + + msg := &a2a.Message{ + Role: a2a.MessageRoleUser, + Parts: []a2a.Part{a2a.NewTextPart("hello world")}, + } + + task, err := rt.Invoke(context.Background(), "t-1", msg) + if err != nil { + t.Fatalf("Invoke error: %v", err) + } + + if task.ID != "t-1" { + t.Errorf("id: got %q", task.ID) + } + if task.Status.State != a2a.TaskStateCompleted { + t.Errorf("state: got %q", task.Status.State) + } + + // Check mock text contains input + if task.Status.Message == nil { + t.Fatal("expected status message") + } + text := task.Status.Message.Parts[0].Text + if !strings.Contains(text, "hello world") { + t.Errorf("response should contain input, got: %q", text) + } + if !strings.Contains(text, "search") { + t.Errorf("response should mention tools, got: %q", text) + } +} + +func TestMockRuntime_Stream(t *testing.T) { + rt := NewMockRuntime(nil) + + msg := &a2a.Message{ + Role: a2a.MessageRoleUser, + Parts: []a2a.Part{a2a.NewTextPart("test")}, + } + + ch, err := rt.Stream(context.Background(), "t-2", msg) + if err != nil { + t.Fatalf("Stream error: %v", err) + } + + count := 0 + for task := range ch { + count++ + if task.ID != "t-2" { + t.Errorf("id: got %q", task.ID) + } + } + if count != 1 { + t.Errorf("expected 1 event, got %d", count) + } +} + +func TestMockRuntime_NoOps(t *testing.T) { + rt := NewMockRuntime(nil) + + if err := rt.Start(context.Background()); err != nil { + t.Errorf("Start: %v", err) + } + if err := rt.Stop(); err != nil { + t.Errorf("Stop: %v", err) + } + if err := rt.Restart(context.Background()); err != nil { + t.Errorf("Restart: %v", err) + } + if !rt.Healthy(context.Background()) { + t.Error("expected healthy=true") + } +} + +func TestMockRuntime_NoTextInput(t *testing.T) { + rt := NewMockRuntime(nil) + + msg := &a2a.Message{ + Role: a2a.MessageRoleUser, + Parts: []a2a.Part{a2a.NewDataPart(map[string]string{"key": "val"})}, + } + + task, err := rt.Invoke(context.Background(), "t-3", msg) + if err != nil { + t.Fatalf("Invoke error: %v", err) + } + + text := task.Status.Message.Parts[0].Text + if !strings.Contains(text, "") { + t.Errorf("expected '' for non-text input, got: %q", text) + } +} diff --git a/forge-cli/runtime/runner.go b/forge-cli/runtime/runner.go new file mode 100644 index 0000000..acc03e5 --- /dev/null +++ b/forge-cli/runtime/runner.go @@ -0,0 +1,644 @@ +package runtime + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/initializ/forge/forge-cli/server" + cliskills "github.com/initializ/forge/forge-cli/skills" + clitools "github.com/initializ/forge/forge-cli/tools" + "github.com/initializ/forge/forge-core/a2a" + "github.com/initializ/forge/forge-core/agentspec" + "github.com/initializ/forge/forge-core/llm/providers" + coreruntime "github.com/initializ/forge/forge-core/runtime" + coreskills "github.com/initializ/forge/forge-core/skills" + "github.com/initializ/forge/forge-core/tools" + "github.com/initializ/forge/forge-core/tools/builtins" + "github.com/initializ/forge/forge-core/types" +) + +// RunnerConfig holds configuration for the Runner. +type RunnerConfig struct { + Config *types.ForgeConfig + WorkDir string + Port int + MockTools bool + EnforceGuardrails bool + ModelOverride string + ProviderOverride string + EnvFilePath string + Verbose bool + Channels []string // active channel adapters from --with flag +} + +// Runner orchestrates the local A2A development server. +type Runner struct { + cfg RunnerConfig + logger coreruntime.Logger + cliExecTool *clitools.CLIExecuteTool +} + +// NewRunner creates a Runner from the given config. +func NewRunner(cfg RunnerConfig) (*Runner, error) { + if cfg.Config == nil { + return nil, fmt.Errorf("config is required") + } + if cfg.Port <= 0 { + cfg.Port = 8080 + } + logger := coreruntime.NewJSONLogger(os.Stderr, cfg.Verbose) + return &Runner{cfg: cfg, logger: logger}, nil +} + +// Run starts the development server. It blocks until ctx is cancelled. +func (r *Runner) Run(ctx context.Context) error { + // 1. Load .env file + envVars, err := LoadEnvFile(r.cfg.EnvFilePath) + if err != nil { + return fmt.Errorf("loading env file: %w", err) + } + + // Apply model override + if r.cfg.ModelOverride != "" { + envVars["MODEL_NAME"] = r.cfg.ModelOverride + } + + // 1b. Validate skill requirements + if err := r.validateSkillRequirements(envVars); err != nil { + return err + } + + // 2. Load policy scaffold + scaffold, err := LoadPolicyScaffold(r.cfg.WorkDir) + if err != nil { + r.logger.Warn("failed to load policy scaffold", map[string]any{"error": err.Error()}) + } + guardrails := coreruntime.NewGuardrailEngine(scaffold, r.cfg.EnforceGuardrails, r.logger) + + // 3. Build agent card + card, err := BuildAgentCard(r.cfg.WorkDir, r.cfg.Config, r.cfg.Port) + if err != nil { + return fmt.Errorf("building agent card: %w", err) + } + + // 4. Choose executor and optional lifecycle runtime + var executor coreruntime.AgentExecutor + var lifecycle coreruntime.AgentRuntime // optional, for subprocess lifecycle management + if r.cfg.MockTools { + toolSpecs := r.loadToolSpecs() + executor = NewMockExecutor(toolSpecs) + r.logger.Info("using mock executor", map[string]any{"tools": len(toolSpecs)}) + } else { + switch r.cfg.Config.Framework { + case "crewai", "langchain": + rt := NewSubprocessRuntime(r.cfg.Config.Entrypoint, r.cfg.WorkDir, envVars, r.logger) + lifecycle = rt + executor = NewSubprocessExecutor(rt) + default: + // Custom framework — build tool registry and try LLM executor + reg := tools.NewRegistry() + if err := builtins.RegisterAll(reg); err != nil { + r.logger.Warn("failed to register builtin tools", map[string]any{"error": err.Error()}) + } + + // Register cli_execute if configured + for _, toolRef := range r.cfg.Config.Tools { + if toolRef.Name == "cli_execute" && toolRef.Config != nil { + cliCfg := clitools.ParseCLIExecuteConfig(toolRef.Config) + if len(cliCfg.AllowedBinaries) > 0 { + r.cliExecTool = clitools.NewCLIExecuteTool(cliCfg) + if regErr := reg.Register(r.cliExecTool); regErr != nil { + r.logger.Warn("failed to register cli_execute", map[string]any{"error": regErr.Error()}) + } else { + avail, missing := r.cliExecTool.Availability() + r.logger.Info("cli_execute registered", map[string]any{ + "available": len(avail), "missing": len(missing), + }) + } + } + break + } + } + + // Discover custom tools in tools/ directory + toolsDir := filepath.Join(r.cfg.WorkDir, "tools") + discovered := clitools.DiscoverTools(toolsDir) + cmdExec := &clitools.OSCommandExecutor{} + for _, dt := range discovered { + ct := tools.NewCustomTool(dt, cmdExec) + if regErr := reg.Register(ct); regErr != nil { + r.logger.Warn("failed to register custom tool", map[string]any{ + "tool": dt.Name, "error": regErr.Error(), + }) + } + } + if len(discovered) > 0 { + r.logger.Info("discovered custom tools", map[string]any{"count": len(discovered)}) + } + + // Log registered tool names + toolNames := reg.List() + r.logger.Info("registered tools", map[string]any{"tools": toolNames}) + + // Try LLM executor, fall back to stub + mc := coreruntime.ResolveModelConfig(r.cfg.Config, envVars, r.cfg.ProviderOverride) + if mc != nil { + llmClient, llmErr := providers.NewClient(mc.Provider, mc.Client) + if llmErr != nil { + r.logger.Warn("failed to create LLM client, using stub", map[string]any{"error": llmErr.Error()}) + executor = NewStubExecutor(r.cfg.Config.Framework) + } else { + // Build logging hooks for agent loop observability + hooks := coreruntime.NewHookRegistry() + r.registerLoggingHooks(hooks) + + executor = coreruntime.NewLLMExecutor(coreruntime.LLMExecutorConfig{ + Client: llmClient, + Tools: reg, + Hooks: hooks, + SystemPrompt: fmt.Sprintf("You are %s, an AI agent.", r.cfg.Config.AgentID), + }) + r.logger.Info("using LLM executor", map[string]any{ + "provider": mc.Provider, + "model": mc.Client.Model, + "tools": len(toolNames), + }) + } + } else { + executor = NewStubExecutor(r.cfg.Config.Framework) + r.logger.Warn("no LLM provider configured, using stub executor", map[string]any{ + "framework": r.cfg.Config.Framework, + }) + } + } + } + defer executor.Close() //nolint:errcheck + + // Start lifecycle runtime if present + if lifecycle != nil { + if err := lifecycle.Start(ctx); err != nil { + return fmt.Errorf("starting runtime: %w", err) + } + defer lifecycle.Stop() //nolint:errcheck + } + + // 5. Create A2A server + srv := server.NewServer(server.ServerConfig{ + Port: r.cfg.Port, + AgentCard: card, + }) + + // 6. Register JSON-RPC handlers + r.registerHandlers(srv, executor, guardrails) + + // 7. Start file watcher + watchCtx, watchCancel := context.WithCancel(ctx) + defer watchCancel() + + watcher := NewFileWatcher(r.cfg.WorkDir, func() { + // Reload config and agent card + newCard, err := BuildAgentCard(r.cfg.WorkDir, r.cfg.Config, r.cfg.Port) + if err != nil { + r.logger.Error("failed to reload agent card", map[string]any{"error": err.Error()}) + } else { + srv.UpdateAgentCard(newCard) + r.logger.Info("agent card reloaded", nil) + } + + // Restart subprocess lifecycle (no-op if lifecycle is nil) + if lifecycle != nil { + if err := lifecycle.Restart(ctx); err != nil { + r.logger.Error("failed to restart runtime", map[string]any{"error": err.Error()}) + } + } + }, r.logger) + go watcher.Watch(watchCtx) + + // 8. Print startup banner + r.printBanner() + + // 9. Start server (blocks) + return srv.Start(ctx) +} + +func (r *Runner) registerHandlers(srv *server.Server, executor coreruntime.AgentExecutor, guardrails *coreruntime.GuardrailEngine) { + store := srv.TaskStore() + + // tasks/send — synchronous request + srv.RegisterHandler("tasks/send", func(ctx context.Context, id any, rawParams json.RawMessage) *a2a.JSONRPCResponse { + var params a2a.SendTaskParams + if err := json.Unmarshal(rawParams, ¶ms); err != nil { + return a2a.NewErrorResponse(id, a2a.ErrCodeInvalidParams, "invalid params: "+err.Error()) + } + + r.logger.Info("tasks/send", map[string]any{"task_id": params.ID}) + + // Create task in submitted state + task := &a2a.Task{ + ID: params.ID, + Status: a2a.TaskStatus{State: a2a.TaskStateSubmitted}, + } + store.Put(task) + + // Guardrail check inbound + if err := guardrails.CheckInbound(¶ms.Message); err != nil { + task.Status = a2a.TaskStatus{ + State: a2a.TaskStateFailed, + Message: &a2a.Message{ + Role: a2a.MessageRoleAgent, + Parts: []a2a.Part{a2a.NewTextPart("Guardrail violation: " + err.Error())}, + }, + } + store.Put(task) + return a2a.NewResponse(id, task) + } + + // Update to working + store.UpdateStatus(params.ID, a2a.TaskStatus{State: a2a.TaskStateWorking}) + task.Status = a2a.TaskStatus{State: a2a.TaskStateWorking} + + // Execute via executor + respMsg, err := executor.Execute(ctx, task, ¶ms.Message) + if err != nil { + r.logger.Error("execute failed", map[string]any{"task_id": params.ID, "error": err.Error()}) + task.Status = a2a.TaskStatus{ + State: a2a.TaskStateFailed, + Message: &a2a.Message{ + Role: a2a.MessageRoleAgent, + Parts: []a2a.Part{a2a.NewTextPart(err.Error())}, + }, + } + store.Put(task) + return a2a.NewResponse(id, task) + } + + // Guardrail check outbound + if respMsg != nil { + if err := guardrails.CheckOutbound(respMsg); err != nil { + task.Status = a2a.TaskStatus{ + State: a2a.TaskStateFailed, + Message: &a2a.Message{ + Role: a2a.MessageRoleAgent, + Parts: []a2a.Part{a2a.NewTextPart("Outbound guardrail violation: " + err.Error())}, + }, + } + store.Put(task) + return a2a.NewResponse(id, task) + } + } + + // Build completed task + task.Status = a2a.TaskStatus{ + State: a2a.TaskStateCompleted, + Message: respMsg, + } + if respMsg != nil { + task.Artifacts = []a2a.Artifact{ + { + Name: "response", + Parts: respMsg.Parts, + }, + } + } + store.Put(task) + r.logger.Info("task completed", map[string]any{"task_id": params.ID, "state": string(task.Status.State)}) + return a2a.NewResponse(id, task) + }) + + // tasks/sendSubscribe — SSE streaming + srv.RegisterSSEHandler("tasks/sendSubscribe", func(ctx context.Context, id any, rawParams json.RawMessage, w http.ResponseWriter, flusher http.Flusher) { + var params a2a.SendTaskParams + if err := json.Unmarshal(rawParams, ¶ms); err != nil { + server.WriteSSEEvent(w, flusher, "error", a2a.NewErrorResponse(id, a2a.ErrCodeInvalidParams, err.Error())) //nolint:errcheck + return + } + + r.logger.Info("tasks/sendSubscribe", map[string]any{"task_id": params.ID}) + + // Create task + task := &a2a.Task{ + ID: params.ID, + Status: a2a.TaskStatus{State: a2a.TaskStateSubmitted}, + } + store.Put(task) + server.WriteSSEEvent(w, flusher, "status", task) //nolint:errcheck + + // Guardrail check inbound + if err := guardrails.CheckInbound(¶ms.Message); err != nil { + task.Status = a2a.TaskStatus{ + State: a2a.TaskStateFailed, + Message: &a2a.Message{ + Role: a2a.MessageRoleAgent, + Parts: []a2a.Part{a2a.NewTextPart("Guardrail violation: " + err.Error())}, + }, + } + store.Put(task) + server.WriteSSEEvent(w, flusher, "status", task) //nolint:errcheck + return + } + + // Update to working + task.Status = a2a.TaskStatus{State: a2a.TaskStateWorking} + store.Put(task) + server.WriteSSEEvent(w, flusher, "status", task) //nolint:errcheck + + // Stream from executor + ch, err := executor.ExecuteStream(ctx, task, ¶ms.Message) + if err != nil { + task.Status = a2a.TaskStatus{ + State: a2a.TaskStateFailed, + Message: &a2a.Message{ + Role: a2a.MessageRoleAgent, + Parts: []a2a.Part{a2a.NewTextPart(err.Error())}, + }, + } + store.Put(task) + server.WriteSSEEvent(w, flusher, "status", task) //nolint:errcheck + return + } + + for respMsg := range ch { + // Guardrail check outbound + if grErr := guardrails.CheckOutbound(respMsg); grErr != nil { + task.Status = a2a.TaskStatus{ + State: a2a.TaskStateFailed, + Message: &a2a.Message{ + Role: a2a.MessageRoleAgent, + Parts: []a2a.Part{a2a.NewTextPart("Outbound guardrail violation: " + grErr.Error())}, + }, + } + store.Put(task) + server.WriteSSEEvent(w, flusher, "result", task) //nolint:errcheck + return + } + + // Build completed result + task.Status = a2a.TaskStatus{ + State: a2a.TaskStateCompleted, + Message: respMsg, + } + task.Artifacts = []a2a.Artifact{ + { + Name: "response", + Parts: respMsg.Parts, + }, + } + store.Put(task) + server.WriteSSEEvent(w, flusher, "result", task) //nolint:errcheck + } + }) + + // tasks/get — lookup task by ID + srv.RegisterHandler("tasks/get", func(ctx context.Context, id any, rawParams json.RawMessage) *a2a.JSONRPCResponse { + var params a2a.GetTaskParams + if err := json.Unmarshal(rawParams, ¶ms); err != nil { + return a2a.NewErrorResponse(id, a2a.ErrCodeInvalidParams, "invalid params: "+err.Error()) + } + + task := store.Get(params.ID) + if task == nil { + return a2a.NewErrorResponse(id, a2a.ErrCodeInvalidParams, "task not found: "+params.ID) + } + return a2a.NewResponse(id, task) + }) + + // tasks/cancel — cancel a task + srv.RegisterHandler("tasks/cancel", func(ctx context.Context, id any, rawParams json.RawMessage) *a2a.JSONRPCResponse { + var params a2a.CancelTaskParams + if err := json.Unmarshal(rawParams, ¶ms); err != nil { + return a2a.NewErrorResponse(id, a2a.ErrCodeInvalidParams, "invalid params: "+err.Error()) + } + + task := store.Get(params.ID) + if task == nil { + return a2a.NewErrorResponse(id, a2a.ErrCodeInvalidParams, "task not found: "+params.ID) + } + + task.Status = a2a.TaskStatus{State: a2a.TaskStateCanceled} + store.Put(task) + r.logger.Info("task canceled", map[string]any{"task_id": params.ID}) + return a2a.NewResponse(id, task) + }) +} + +func (r *Runner) loadToolSpecs() []agentspec.ToolSpec { + var toolSpecs []agentspec.ToolSpec + for _, t := range r.cfg.Config.Tools { + toolSpecs = append(toolSpecs, agentspec.ToolSpec{Name: t.Name}) + } + return toolSpecs +} + +// registerLoggingHooks adds observability hooks to the LLM executor's agent loop. +func (r *Runner) registerLoggingHooks(hooks *coreruntime.HookRegistry) { + hooks.Register(coreruntime.AfterLLMCall, func(_ context.Context, hctx *coreruntime.HookContext) error { + if hctx.Response == nil { + return nil + } + fields := map[string]any{ + "finish_reason": hctx.Response.FinishReason, + } + if hctx.Response.Usage.TotalTokens > 0 { + fields["tokens"] = hctx.Response.Usage.TotalTokens + } + if len(hctx.Response.Message.ToolCalls) > 0 { + names := make([]string, len(hctx.Response.Message.ToolCalls)) + for i, tc := range hctx.Response.Message.ToolCalls { + names[i] = tc.Function.Name + } + fields["tool_calls"] = names + } + if hctx.Response.Message.Content != "" { + content := hctx.Response.Message.Content + if len(content) > 200 { + content = content[:200] + "..." + } + fields["response"] = content + } + r.logger.Info("llm response", fields) + return nil + }) + + hooks.Register(coreruntime.BeforeToolExec, func(_ context.Context, hctx *coreruntime.HookContext) error { + fields := map[string]any{"tool": hctx.ToolName} + if hctx.ToolInput != "" { + input := hctx.ToolInput + if len(input) > 300 { + input = input[:300] + "..." + } + fields["input"] = input + } + r.logger.Info("tool call", fields) + return nil + }) + + hooks.Register(coreruntime.AfterToolExec, func(_ context.Context, hctx *coreruntime.HookContext) error { + fields := map[string]any{"tool": hctx.ToolName} + if hctx.Error != nil { + fields["error"] = hctx.Error.Error() + r.logger.Error("tool error", fields) + } else { + output := hctx.ToolOutput + if len(output) > 500 { + output = output[:500] + "..." + } + fields["output_length"] = len(hctx.ToolOutput) + fields["output"] = output + r.logger.Info("tool result", fields) + } + return nil + }) + + hooks.Register(coreruntime.OnError, func(_ context.Context, hctx *coreruntime.HookContext) error { + if hctx.Error != nil { + r.logger.Error("agent loop error", map[string]any{"error": hctx.Error.Error()}) + } + return nil + }) +} + +func (r *Runner) printBanner() { + fmt.Fprintf(os.Stderr, "\n") + fmt.Fprintf(os.Stderr, " Forge Dev Server\n") + fmt.Fprintf(os.Stderr, " ────────────────────────────────────────\n") + fmt.Fprintf(os.Stderr, " Agent: %s (v%s)\n", r.cfg.Config.AgentID, r.cfg.Config.Version) + fmt.Fprintf(os.Stderr, " Framework: %s\n", r.cfg.Config.Framework) + fmt.Fprintf(os.Stderr, " Port: %d\n", r.cfg.Port) + if r.cfg.MockTools { + fmt.Fprintf(os.Stderr, " Mode: mock (no subprocess)\n") + } else { + fmt.Fprintf(os.Stderr, " Entrypoint: %s\n", r.cfg.Config.Entrypoint) + } + // Tools + if len(r.cfg.Config.Tools) > 0 { + names := make([]string, 0, len(r.cfg.Config.Tools)) + for _, t := range r.cfg.Config.Tools { + names = append(names, t.Name) + } + fmt.Fprintf(os.Stderr, " Tools: %d (%s)\n", len(names), strings.Join(names, ", ")) + } + // CLI Exec binaries + if r.cliExecTool != nil { + avail, missing := r.cliExecTool.Availability() + total := len(avail) + len(missing) + parts := make([]string, 0, total) + for _, b := range avail { + parts = append(parts, b+" ok") + } + for _, b := range missing { + parts = append(parts, b+" MISSING") + } + fmt.Fprintf(os.Stderr, " CLI Exec: %d/%d binaries (%s)\n", len(avail), total, strings.Join(parts, ", ")) + } + // Channels + if len(r.cfg.Channels) > 0 { + fmt.Fprintf(os.Stderr, " Channels: %s\n", strings.Join(r.cfg.Channels, ", ")) + } + // Egress + if r.cfg.Config.Egress.Profile != "" || r.cfg.Config.Egress.Mode != "" { + fmt.Fprintf(os.Stderr, " Egress: %s / %s\n", + defaultStr(r.cfg.Config.Egress.Profile, "strict"), + defaultStr(r.cfg.Config.Egress.Mode, "deny-all")) + } + fmt.Fprintf(os.Stderr, " ────────────────────────────────────────\n") + fmt.Fprintf(os.Stderr, " Agent Card: http://localhost:%d/.well-known/agent.json\n", r.cfg.Port) + fmt.Fprintf(os.Stderr, " Health: http://localhost:%d/healthz\n", r.cfg.Port) + fmt.Fprintf(os.Stderr, " JSON-RPC: POST http://localhost:%d/\n", r.cfg.Port) + fmt.Fprintf(os.Stderr, " ────────────────────────────────────────\n") + fmt.Fprintf(os.Stderr, " Press Ctrl+C to stop\n\n") +} + +// validateSkillRequirements loads skill requirements and validates them. +// It also auto-derives cli_execute config from skill requirements. +func (r *Runner) validateSkillRequirements(envVars map[string]string) error { + // Resolve skills file path + skillsPath := "skills.md" + if r.cfg.Config.Skills.Path != "" { + skillsPath = r.cfg.Config.Skills.Path + } + if !filepath.IsAbs(skillsPath) { + skillsPath = filepath.Join(r.cfg.WorkDir, skillsPath) + } + + // Skip if file not found + if _, err := os.Stat(skillsPath); os.IsNotExist(err) { + return nil + } + + entries, _, err := cliskills.ParseFileWithMetadata(skillsPath) + if err != nil { + r.logger.Warn("failed to parse skills with metadata", map[string]any{"error": err.Error()}) + return nil + } + + reqs := coreskills.AggregateRequirements(entries) + if len(reqs.Bins) == 0 && len(reqs.EnvRequired) == 0 && len(reqs.EnvOneOf) == 0 && len(reqs.EnvOptional) == 0 { + return nil + } + + // Build env resolver + osEnv := envFromOS() + resolver := coreskills.NewEnvResolver(osEnv, envVars, nil) + + // Check binaries + binDiags := coreskills.BinDiagnostics(reqs.Bins) + for _, d := range binDiags { + r.logger.Warn(d.Message, nil) + } + + // Check env vars + envDiags := resolver.Resolve(reqs) + for _, d := range envDiags { + switch d.Level { + case "error": + return fmt.Errorf("skill requirement not met: %s", d.Message) + case "warning": + r.logger.Warn(d.Message, nil) + } + } + + // Auto-derive cli_execute config from skill requirements + derived := coreskills.DeriveCLIConfig(reqs) + if derived != nil && len(derived.AllowedBinaries) > 0 { + // Check if cli_execute is already explicitly configured + hasExplicit := false + for _, toolRef := range r.cfg.Config.Tools { + if toolRef.Name == "cli_execute" { + hasExplicit = true + break + } + } + + if !hasExplicit { + r.logger.Info("auto-derived cli_execute from skill requirements", map[string]any{ + "binaries": len(derived.AllowedBinaries), + "env_vars": len(derived.EnvPassthrough), + }) + } + } + + return nil +} + +func envFromOS() map[string]string { + env := make(map[string]string) + for _, e := range os.Environ() { + k, v, ok := strings.Cut(e, "=") + if ok { + env[k] = v + } + } + return env +} + +func defaultStr(s, def string) string { + if s != "" { + return s + } + return def +} diff --git a/forge-cli/runtime/runner_test.go b/forge-cli/runtime/runner_test.go new file mode 100644 index 0000000..0652e3f --- /dev/null +++ b/forge-cli/runtime/runner_test.go @@ -0,0 +1,228 @@ +package runtime + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "testing" + "time" + + "github.com/initializ/forge/forge-core/a2a" + "github.com/initializ/forge/forge-core/types" +) + +func TestRunner_MockIntegration(t *testing.T) { + dir := t.TempDir() + + cfg := &types.ForgeConfig{ + AgentID: "test-agent", + Version: "0.1.0", + Framework: "custom", + Entrypoint: "python main.py", + Tools: []types.ToolRef{ + {Name: "search"}, + }, + } + + // Find a free port for the test + port, err := findFreePort() + if err != nil { + t.Fatal(err) + } + + runner, err := NewRunner(RunnerConfig{ + Config: cfg, + WorkDir: dir, + Port: port, + MockTools: true, + Verbose: false, + }) + if err != nil { + t.Fatalf("NewRunner error: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Start runner in background + errCh := make(chan error, 1) + go func() { + errCh <- runner.Run(ctx) + }() + + // Wait for server to be ready + baseURL := fmt.Sprintf("http://localhost:%d", port) + waitForServer(t, baseURL, 5*time.Second) + + // Test healthz + t.Run("healthz", func(t *testing.T) { + resp, err := http.Get(baseURL + "/healthz") + if err != nil { + t.Fatalf("healthz request: %v", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + t.Errorf("status: got %d", resp.StatusCode) + } + }) + + // Test agent card + t.Run("agent card", func(t *testing.T) { + resp, err := http.Get(baseURL + "/.well-known/agent.json") + if err != nil { + t.Fatalf("agent card request: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + var card a2a.AgentCard + json.NewDecoder(resp.Body).Decode(&card) //nolint:errcheck + if card.Name != "test-agent" { + t.Errorf("name: got %q", card.Name) + } + }) + + // Test tasks/send + t.Run("tasks/send", func(t *testing.T) { + rpcReq := a2a.JSONRPCRequest{ + JSONRPC: "2.0", + ID: "1", + Method: "tasks/send", + Params: mustMarshal(a2a.SendTaskParams{ + ID: "t-1", + Message: a2a.Message{ + Role: a2a.MessageRoleUser, + Parts: []a2a.Part{a2a.NewTextPart("hello")}, + }, + }), + } + + body, _ := json.Marshal(rpcReq) + resp, err := http.Post(baseURL+"/", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("send request: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + var rpcResp a2a.JSONRPCResponse + json.NewDecoder(resp.Body).Decode(&rpcResp) //nolint:errcheck + + if rpcResp.Error != nil { + t.Fatalf("unexpected error: %+v", rpcResp.Error) + } + + // Extract task from result + resultData, _ := json.Marshal(rpcResp.Result) + var task a2a.Task + json.Unmarshal(resultData, &task) //nolint:errcheck + + if task.ID != "t-1" { + t.Errorf("task id: got %q", task.ID) + } + if task.Status.State != a2a.TaskStateCompleted { + t.Errorf("state: got %q", task.Status.State) + } + }) + + // Test tasks/get + t.Run("tasks/get", func(t *testing.T) { + rpcReq := a2a.JSONRPCRequest{ + JSONRPC: "2.0", + ID: "2", + Method: "tasks/get", + Params: mustMarshal(a2a.GetTaskParams{ID: "t-1"}), + } + + body, _ := json.Marshal(rpcReq) + resp, err := http.Post(baseURL+"/", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("get request: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + var rpcResp a2a.JSONRPCResponse + json.NewDecoder(resp.Body).Decode(&rpcResp) //nolint:errcheck + + if rpcResp.Error != nil { + t.Fatalf("unexpected error: %+v", rpcResp.Error) + } + }) + + // Test tasks/cancel + t.Run("tasks/cancel", func(t *testing.T) { + rpcReq := a2a.JSONRPCRequest{ + JSONRPC: "2.0", + ID: "3", + Method: "tasks/cancel", + Params: mustMarshal(a2a.CancelTaskParams{ID: "t-1"}), + } + + body, _ := json.Marshal(rpcReq) + resp, err := http.Post(baseURL+"/", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("cancel request: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + var rpcResp a2a.JSONRPCResponse + json.NewDecoder(resp.Body).Decode(&rpcResp) //nolint:errcheck + + if rpcResp.Error != nil { + t.Fatalf("unexpected error: %+v", rpcResp.Error) + } + + resultData, _ := json.Marshal(rpcResp.Result) + var task a2a.Task + json.Unmarshal(resultData, &task) //nolint:errcheck + if task.Status.State != a2a.TaskStateCanceled { + t.Errorf("state: got %q, want %q", task.Status.State, a2a.TaskStateCanceled) + } + }) + + // Shutdown + cancel() +} + +func TestNewRunner_NilConfig(t *testing.T) { + _, err := NewRunner(RunnerConfig{}) + if err == nil { + t.Error("expected error for nil config") + } +} + +func TestNewRunner_DefaultPort(t *testing.T) { + runner, err := NewRunner(RunnerConfig{ + Config: &types.ForgeConfig{ + AgentID: "test", + Version: "0.1.0", + Entrypoint: "python main.py", + }, + }) + if err != nil { + t.Fatal(err) + } + if runner.cfg.Port != 8080 { + t.Errorf("default port: got %d, want 8080", runner.cfg.Port) + } +} + +func waitForServer(t *testing.T, baseURL string, timeout time.Duration) { + t.Helper() + deadline := time.After(timeout) + for { + select { + case <-deadline: + t.Fatalf("server did not start within %v", timeout) + default: + } + resp, err := http.Get(baseURL + "/healthz") + if err == nil { + _ = resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return + } + } + time.Sleep(50 * time.Millisecond) + } +} diff --git a/forge-cli/runtime/stub_executor.go b/forge-cli/runtime/stub_executor.go new file mode 100644 index 0000000..7538aaf --- /dev/null +++ b/forge-cli/runtime/stub_executor.go @@ -0,0 +1,33 @@ +package runtime + +import ( + "context" + "fmt" + + "github.com/initializ/forge/forge-core/a2a" +) + +// StubExecutor implements AgentExecutor by returning an error indicating +// that no LLM configuration is available. Used as a fallback when no +// provider is configured for a custom framework agent. +type StubExecutor struct { + framework string +} + +// NewStubExecutor creates a StubExecutor for the given framework name. +func NewStubExecutor(framework string) *StubExecutor { + return &StubExecutor{framework: framework} +} + +// Execute returns an error indicating execution is not configured. +func (s *StubExecutor) Execute(ctx context.Context, task *a2a.Task, msg *a2a.Message) (*a2a.Message, error) { + return nil, fmt.Errorf("agent execution not configured for framework %q", s.framework) +} + +// ExecuteStream returns an error indicating execution is not configured. +func (s *StubExecutor) ExecuteStream(ctx context.Context, task *a2a.Task, msg *a2a.Message) (<-chan *a2a.Message, error) { + return nil, fmt.Errorf("agent execution not configured for framework %q", s.framework) +} + +// Close is a no-op for StubExecutor. +func (s *StubExecutor) Close() error { return nil } diff --git a/forge-cli/runtime/stub_executor_test.go b/forge-cli/runtime/stub_executor_test.go new file mode 100644 index 0000000..00cc250 --- /dev/null +++ b/forge-cli/runtime/stub_executor_test.go @@ -0,0 +1,53 @@ +package runtime + +import ( + "context" + "strings" + "testing" + + "github.com/initializ/forge/forge-core/a2a" +) + +func TestStubExecutor_Execute(t *testing.T) { + exec := NewStubExecutor("custom") + task := &a2a.Task{ID: "t-1"} + msg := &a2a.Message{ + Role: a2a.MessageRoleUser, + Parts: []a2a.Part{a2a.NewTextPart("test")}, + } + + _, err := exec.Execute(context.Background(), task, msg) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "custom") { + t.Errorf("error should contain framework name, got: %q", err.Error()) + } + if !strings.Contains(err.Error(), "not configured") { + t.Errorf("error should mention not configured, got: %q", err.Error()) + } +} + +func TestStubExecutor_ExecuteStream(t *testing.T) { + exec := NewStubExecutor("langchain") + task := &a2a.Task{ID: "t-1"} + msg := &a2a.Message{ + Role: a2a.MessageRoleUser, + Parts: []a2a.Part{a2a.NewTextPart("test")}, + } + + _, err := exec.ExecuteStream(context.Background(), task, msg) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "langchain") { + t.Errorf("error should contain framework name, got: %q", err.Error()) + } +} + +func TestStubExecutor_Close(t *testing.T) { + exec := NewStubExecutor("custom") + if err := exec.Close(); err != nil { + t.Errorf("Close: %v", err) + } +} diff --git a/forge-cli/runtime/subprocess.go b/forge-cli/runtime/subprocess.go new file mode 100644 index 0000000..ec57d62 --- /dev/null +++ b/forge-cli/runtime/subprocess.go @@ -0,0 +1,316 @@ +package runtime + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "os/exec" + "strings" + "sync" + "time" + + "github.com/initializ/forge/forge-core/a2a" + coreruntime "github.com/initializ/forge/forge-core/runtime" +) + +// SubprocessRuntime manages a child agent process and proxies A2A requests to it. +type SubprocessRuntime struct { + entrypoint string + workDir string + env map[string]string + internalPort int + logger coreruntime.Logger + + mu sync.Mutex + cmd *exec.Cmd +} + +// NewSubprocessRuntime creates a runtime that will start the given entrypoint +// command, passing PORT as an env var for the subprocess to listen on. +func NewSubprocessRuntime(entrypoint, workDir string, env map[string]string, logger coreruntime.Logger) *SubprocessRuntime { + return &SubprocessRuntime{ + entrypoint: entrypoint, + workDir: workDir, + env: env, + logger: logger, + } +} + +// Start launches the subprocess and waits for it to become healthy. +func (s *SubprocessRuntime) Start(ctx context.Context) error { + port, err := findFreePort() + if err != nil { + return fmt.Errorf("finding free port: %w", err) + } + s.internalPort = port + + fields := strings.Fields(s.entrypoint) + if len(fields) == 0 { + return fmt.Errorf("empty entrypoint") + } + + s.mu.Lock() + s.cmd = exec.CommandContext(ctx, fields[0], fields[1:]...) + s.cmd.Dir = s.workDir + + // Build environment + env := os.Environ() + for k, v := range s.env { + env = append(env, k+"="+v) + } + env = append(env, fmt.Sprintf("PORT=%d", s.internalPort)) + s.cmd.Env = env + + // Pipe stderr through logger + stderr, err := s.cmd.StderrPipe() + if err != nil { + s.mu.Unlock() + return fmt.Errorf("stderr pipe: %w", err) + } + // Capture stdout too + s.cmd.Stdout = os.Stdout + + if err := s.cmd.Start(); err != nil { + s.mu.Unlock() + return fmt.Errorf("starting subprocess: %w", err) + } + s.mu.Unlock() + + // Log stderr in background + go s.pipeStderr(stderr) + + // Wait for subprocess to be healthy + s.logger.Info("waiting for subprocess", map[string]any{ + "port": s.internalPort, + "entrypoint": s.entrypoint, + }) + + if err := s.waitForHealth(ctx); err != nil { + s.Stop() //nolint:errcheck + return fmt.Errorf("subprocess health check failed: %w", err) + } + + s.logger.Info("subprocess is healthy", map[string]any{"port": s.internalPort}) + return nil +} + +func (s *SubprocessRuntime) pipeStderr(r io.Reader) { + scanner := bufio.NewScanner(r) + for scanner.Scan() { + s.logger.Debug("subprocess", map[string]any{"stderr": scanner.Text()}) + } +} + +func (s *SubprocessRuntime) waitForHealth(ctx context.Context) error { + deadline := time.After(60 * time.Second) + interval := 100 * time.Millisecond + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-deadline: + return fmt.Errorf("timeout waiting for subprocess on port %d", s.internalPort) + default: + } + + if s.Healthy(ctx) { + return nil + } + time.Sleep(interval) + // Exponential backoff, cap at 2s + if interval < 2*time.Second { + interval = interval * 2 + } + } +} + +// Invoke sends a synchronous tasks/send request to the subprocess. +func (s *SubprocessRuntime) Invoke(ctx context.Context, taskID string, msg *a2a.Message) (*a2a.Task, error) { + reqBody := a2a.JSONRPCRequest{ + JSONRPC: "2.0", + ID: taskID, + Method: "tasks/send", + Params: mustMarshal(a2a.SendTaskParams{ID: taskID, Message: *msg}), + } + + data, _ := json.Marshal(reqBody) + url := fmt.Sprintf("http://127.0.0.1:%d/", s.internalPort) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("proxy request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + var rpcResp a2a.JSONRPCResponse + if err := json.NewDecoder(resp.Body).Decode(&rpcResp); err != nil { + return nil, fmt.Errorf("decoding proxy response: %w", err) + } + + if rpcResp.Error != nil { + return nil, fmt.Errorf("subprocess error %d: %s", rpcResp.Error.Code, rpcResp.Error.Message) + } + + // Extract Task from result + resultData, err := json.Marshal(rpcResp.Result) + if err != nil { + return nil, fmt.Errorf("marshalling result: %w", err) + } + var task a2a.Task + if err := json.Unmarshal(resultData, &task); err != nil { + return nil, fmt.Errorf("unmarshalling task: %w", err) + } + + return &task, nil +} + +// Stream sends a tasks/sendSubscribe request. If the subprocess returns SSE, +// events are streamed; otherwise the response is wrapped as a single item. +func (s *SubprocessRuntime) Stream(ctx context.Context, taskID string, msg *a2a.Message) (<-chan *a2a.Task, error) { + reqBody := a2a.JSONRPCRequest{ + JSONRPC: "2.0", + ID: taskID, + Method: "tasks/sendSubscribe", + Params: mustMarshal(a2a.SendTaskParams{ID: taskID, Message: *msg}), + } + + data, _ := json.Marshal(reqBody) + url := fmt.Sprintf("http://127.0.0.1:%d/", s.internalPort) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("proxy request: %w", err) + } + + ch := make(chan *a2a.Task, 8) + + contentType := resp.Header.Get("Content-Type") + if strings.HasPrefix(contentType, "text/event-stream") { + // Parse SSE events + go func() { + defer func() { _ = resp.Body.Close() }() + defer close(ch) + s.readSSEEvents(resp.Body, ch) + }() + } else { + // Graceful degradation: wrap sync response + go func() { + defer func() { _ = resp.Body.Close() }() + defer close(ch) + + var rpcResp a2a.JSONRPCResponse + if err := json.NewDecoder(resp.Body).Decode(&rpcResp); err != nil { + return + } + if rpcResp.Error != nil || rpcResp.Result == nil { + return + } + resultData, _ := json.Marshal(rpcResp.Result) + var task a2a.Task + if json.Unmarshal(resultData, &task) == nil { + ch <- &task + } + }() + } + + return ch, nil +} + +func (s *SubprocessRuntime) readSSEEvents(r io.Reader, ch chan<- *a2a.Task) { + scanner := bufio.NewScanner(r) + var dataLine string + for scanner.Scan() { + line := scanner.Text() + if after, ok := strings.CutPrefix(line, "data: "); ok { + dataLine = after + } else if line == "" && dataLine != "" { + var task a2a.Task + if json.Unmarshal([]byte(dataLine), &task) == nil { + ch <- &task + } + dataLine = "" + } + } +} + +// Healthy checks if the subprocess is responding. +func (s *SubprocessRuntime) Healthy(ctx context.Context) bool { + url := fmt.Sprintf("http://127.0.0.1:%d/healthz", s.internalPort) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return false + } + client := &http.Client{Timeout: 2 * time.Second} + resp, err := client.Do(req) + if err != nil { + return false + } + _ = resp.Body.Close() + return resp.StatusCode == http.StatusOK +} + +// Stop sends SIGTERM, waits 5s, then SIGKILL if needed. +func (s *SubprocessRuntime) Stop() error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.cmd == nil || s.cmd.Process == nil { + return nil + } + + // Send interrupt signal + s.cmd.Process.Signal(os.Interrupt) //nolint:errcheck + + done := make(chan error, 1) + go func() { done <- s.cmd.Wait() }() + + select { + case <-done: + return nil + case <-time.After(5 * time.Second): + s.cmd.Process.Kill() //nolint:errcheck + return nil + } +} + +// Restart stops and re-starts the subprocess. +func (s *SubprocessRuntime) Restart(ctx context.Context) error { + s.logger.Info("restarting subprocess", nil) + if err := s.Stop(); err != nil { + s.logger.Warn("stop error during restart", map[string]any{"error": err.Error()}) + } + return s.Start(ctx) +} + +// findFreePort binds to port 0, reads the assigned port, and closes. +func findFreePort() (int, error) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return 0, err + } + port := ln.Addr().(*net.TCPAddr).Port + _ = ln.Close() + return port, nil +} + +func mustMarshal(v any) json.RawMessage { + data, _ := json.Marshal(v) + return data +} diff --git a/forge-cli/runtime/subprocess_executor.go b/forge-cli/runtime/subprocess_executor.go new file mode 100644 index 0000000..9fe73c8 --- /dev/null +++ b/forge-cli/runtime/subprocess_executor.go @@ -0,0 +1,51 @@ +package runtime + +import ( + "context" + + "github.com/initializ/forge/forge-core/a2a" +) + +// SubprocessExecutor wraps a SubprocessRuntime to implement AgentExecutor. +// It delegates to the runtime's Invoke/Stream methods and extracts the +// message from the returned task. +type SubprocessExecutor struct { + rt *SubprocessRuntime +} + +// NewSubprocessExecutor creates an executor that delegates to the given runtime. +func NewSubprocessExecutor(rt *SubprocessRuntime) *SubprocessExecutor { + return &SubprocessExecutor{rt: rt} +} + +// Execute calls the runtime's Invoke and extracts the status message. +func (s *SubprocessExecutor) Execute(ctx context.Context, task *a2a.Task, msg *a2a.Message) (*a2a.Message, error) { + result, err := s.rt.Invoke(ctx, task.ID, msg) + if err != nil { + return nil, err + } + return result.Status.Message, nil +} + +// ExecuteStream calls the runtime's Stream and converts task updates to messages. +func (s *SubprocessExecutor) ExecuteStream(ctx context.Context, task *a2a.Task, msg *a2a.Message) (<-chan *a2a.Message, error) { + taskCh, err := s.rt.Stream(ctx, task.ID, msg) + if err != nil { + return nil, err + } + + msgCh := make(chan *a2a.Message, 8) + go func() { + defer close(msgCh) + for update := range taskCh { + if update.Status.Message != nil { + msgCh <- update.Status.Message + } + } + }() + + return msgCh, nil +} + +// Close is a no-op; the subprocess lifecycle is managed by SubprocessRuntime. +func (s *SubprocessExecutor) Close() error { return nil } diff --git a/forge-cli/runtime/subprocess_executor_test.go b/forge-cli/runtime/subprocess_executor_test.go new file mode 100644 index 0000000..23f9d64 --- /dev/null +++ b/forge-cli/runtime/subprocess_executor_test.go @@ -0,0 +1,137 @@ +package runtime + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + + "github.com/initializ/forge/forge-core/a2a" + coreruntime "github.com/initializ/forge/forge-core/runtime" +) + +func TestSubprocessExecutor_Execute(t *testing.T) { + // Create a mock A2A server + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req a2a.JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) //nolint:errcheck + + task := &a2a.Task{ + ID: "t-1", + Status: a2a.TaskStatus{ + State: a2a.TaskStateCompleted, + Message: &a2a.Message{ + Role: a2a.MessageRoleAgent, + Parts: []a2a.Part{a2a.NewTextPart("subprocess response")}, + }, + }, + } + + resp := a2a.NewResponse(req.ID, task) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) //nolint:errcheck + })) + defer ts.Close() + + // Parse port from test server URL (http://127.0.0.1:PORT) + parts := strings.Split(ts.URL, ":") + port, err := strconv.Atoi(parts[len(parts)-1]) + if err != nil { + t.Fatalf("parse port: %v", err) + } + + rt := &SubprocessRuntime{ + internalPort: port, + logger: coreruntime.NewJSONLogger(nil, false), + } + + exec := NewSubprocessExecutor(rt) + task := &a2a.Task{ID: "t-1"} + msg := &a2a.Message{ + Role: a2a.MessageRoleUser, + Parts: []a2a.Part{a2a.NewTextPart("hello")}, + } + + resp, err := exec.Execute(context.Background(), task, msg) + if err != nil { + t.Fatalf("Execute error: %v", err) + } + + if resp == nil { + t.Fatal("expected non-nil response") + } + if resp.Parts[0].Text != "subprocess response" { + t.Errorf("text: got %q", resp.Parts[0].Text) + } +} + +func TestSubprocessExecutor_ExecuteStream(t *testing.T) { + // Create a mock A2A server that returns a JSON response (non-SSE) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req a2a.JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) //nolint:errcheck + + task := &a2a.Task{ + ID: "t-2", + Status: a2a.TaskStatus{ + State: a2a.TaskStateCompleted, + Message: &a2a.Message{ + Role: a2a.MessageRoleAgent, + Parts: []a2a.Part{a2a.NewTextPart("stream response")}, + }, + }, + } + + resp := a2a.NewResponse(req.ID, task) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) //nolint:errcheck + })) + defer ts.Close() + + parts := strings.Split(ts.URL, ":") + port, err := strconv.Atoi(parts[len(parts)-1]) + if err != nil { + t.Fatalf("parse port: %v", err) + } + + rt := &SubprocessRuntime{ + internalPort: port, + logger: coreruntime.NewJSONLogger(nil, false), + } + + exec := NewSubprocessExecutor(rt) + task := &a2a.Task{ID: "t-2"} + msg := &a2a.Message{ + Role: a2a.MessageRoleUser, + Parts: []a2a.Part{a2a.NewTextPart("hello")}, + } + + ch, err := exec.ExecuteStream(context.Background(), task, msg) + if err != nil { + t.Fatalf("ExecuteStream error: %v", err) + } + + count := 0 + for resp := range ch { + count++ + if resp.Parts[0].Text != "stream response" { + t.Errorf("text: got %q", resp.Parts[0].Text) + } + } + if count != 1 { + t.Errorf("expected 1 message, got %d", count) + } +} + +func TestSubprocessExecutor_Close(t *testing.T) { + rt := &SubprocessRuntime{ + logger: coreruntime.NewJSONLogger(nil, false), + } + exec := NewSubprocessExecutor(rt) + if err := exec.Close(); err != nil { + t.Errorf("Close: %v", err) + } +} diff --git a/forge-cli/runtime/subprocess_test.go b/forge-cli/runtime/subprocess_test.go new file mode 100644 index 0000000..891a611 --- /dev/null +++ b/forge-cli/runtime/subprocess_test.go @@ -0,0 +1,126 @@ +package runtime + +import ( + "bytes" + "context" + "encoding/json" + "net" + "net/http" + "testing" + + "github.com/initializ/forge/forge-core/a2a" + coreruntime "github.com/initializ/forge/forge-core/runtime" +) + +func TestFindFreePort(t *testing.T) { + port, err := findFreePort() + if err != nil { + t.Fatalf("findFreePort error: %v", err) + } + if port <= 0 { + t.Errorf("invalid port: %d", port) + } + + // Verify the port is actually free by binding to it + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen error: %v", err) + } + _ = ln.Close() +} + +func TestFindFreePort_Unique(t *testing.T) { + ports := make(map[int]bool) + for range 10 { + port, err := findFreePort() + if err != nil { + t.Fatalf("findFreePort error: %v", err) + } + ports[port] = true + } + // At least some should be unique (OS may reuse, but not all) + if len(ports) < 2 { + t.Error("expected at least 2 unique ports") + } +} + +func TestSubprocessRuntime_EnvMerge(t *testing.T) { + env := map[string]string{ + "API_KEY": "test-key", + "DB_HOST": "localhost", + } + + rt := NewSubprocessRuntime("echo hello", t.TempDir(), env, coreruntime.NewJSONLogger(&bytes.Buffer{}, false)) + + if rt.entrypoint != "echo hello" { + t.Errorf("entrypoint: got %q", rt.entrypoint) + } + if rt.env["API_KEY"] != "test-key" { + t.Error("env not stored correctly") + } +} + +func TestSubprocessRuntime_HealthCheck(t *testing.T) { + // Start a test HTTP server that responds to /healthz + mux := http.NewServeMux() + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status":"ok"}`)) //nolint:errcheck + }) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + port := ln.Addr().(*net.TCPAddr).Port + srv := &http.Server{Handler: mux} + go srv.Serve(ln) //nolint:errcheck + defer func() { _ = srv.Close() }() + + rt := &SubprocessRuntime{ + internalPort: port, + logger: coreruntime.NewJSONLogger(&bytes.Buffer{}, false), + } + + ctx := context.Background() + if !rt.Healthy(ctx) { + t.Error("expected healthy=true for running test server") + } +} + +func TestSubprocessRuntime_HealthCheck_Unhealthy(t *testing.T) { + // Use a port that nothing is listening on + port, _ := findFreePort() + rt := &SubprocessRuntime{ + internalPort: port, + logger: coreruntime.NewJSONLogger(&bytes.Buffer{}, false), + } + + ctx := context.Background() + if rt.Healthy(ctx) { + t.Error("expected healthy=false when nothing is listening") + } +} + +func TestMustMarshal(t *testing.T) { + params := a2a.SendTaskParams{ + ID: "t-1", + Message: a2a.Message{ + Role: a2a.MessageRoleUser, + Parts: []a2a.Part{a2a.NewTextPart("hi")}, + }, + } + + data := mustMarshal(params) + if len(data) == 0 { + t.Error("expected non-empty JSON") + } + + var decoded a2a.SendTaskParams + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if decoded.ID != "t-1" { + t.Errorf("id: got %q", decoded.ID) + } +} diff --git a/forge-cli/runtime/watcher.go b/forge-cli/runtime/watcher.go new file mode 100644 index 0000000..4cf53ec --- /dev/null +++ b/forge-cli/runtime/watcher.go @@ -0,0 +1,127 @@ +package runtime + +import ( + "context" + "os" + "path/filepath" + "strings" + "sync" + "time" + + coreruntime "github.com/initializ/forge/forge-core/runtime" +) + +// FileWatcher polls the filesystem for changes and invokes a callback. +type FileWatcher struct { + dir string + onChange func() + logger coreruntime.Logger + interval time.Duration + debounce time.Duration + mu sync.Mutex + lastModMap map[string]time.Time +} + +// NewFileWatcher creates a watcher that polls dir every 2s for changes in +// watched file types. onChange is called (debounced) when changes are detected. +func NewFileWatcher(dir string, onChange func(), logger coreruntime.Logger) *FileWatcher { + return &FileWatcher{ + dir: dir, + onChange: onChange, + logger: logger, + interval: 2 * time.Second, + debounce: 500 * time.Millisecond, + lastModMap: make(map[string]time.Time), + } +} + +var watchedExtensions = map[string]bool{ + ".py": true, ".go": true, ".ts": true, ".js": true, ".yaml": true, ".yml": true, +} + +var skippedDirs = map[string]bool{ + ".git": true, "node_modules": true, "__pycache__": true, + ".forge-output": true, "venv": true, ".venv": true, +} + +// Watch starts polling until ctx is cancelled. It blocks. +func (w *FileWatcher) Watch(ctx context.Context) { + // Build initial snapshot + w.mu.Lock() + w.lastModMap = w.scan() + w.mu.Unlock() + + ticker := time.NewTicker(w.interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if w.detectChanges() { + w.logger.Info("file change detected, reloading", nil) + // Debounce: wait briefly for batched writes + time.Sleep(w.debounce) + w.onChange() + } + } + } +} + +func (w *FileWatcher) scan() map[string]time.Time { + modMap := make(map[string]time.Time) + _ = filepath.WalkDir(w.dir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + if skippedDirs[d.Name()] { + return filepath.SkipDir + } + return nil + } + ext := strings.ToLower(filepath.Ext(d.Name())) + if !watchedExtensions[ext] { + return nil + } + info, err := d.Info() + if err != nil { + return nil + } + modMap[path] = info.ModTime() + return nil + }) + return modMap +} + +func (w *FileWatcher) detectChanges() bool { + w.mu.Lock() + defer w.mu.Unlock() + + current := w.scan() + changed := false + + // Check for new or modified files + for path, modTime := range current { + if prev, ok := w.lastModMap[path]; !ok || !modTime.Equal(prev) { + changed = true + break + } + } + + // Check for deleted files + if !changed { + for path := range w.lastModMap { + if _, ok := current[path]; !ok { + changed = true + break + } + } + } + + if changed { + w.lastModMap = current + } + return changed +} diff --git a/forge-cli/runtime/watcher_test.go b/forge-cli/runtime/watcher_test.go new file mode 100644 index 0000000..4e7c389 --- /dev/null +++ b/forge-cli/runtime/watcher_test.go @@ -0,0 +1,116 @@ +package runtime + +import ( + "bytes" + "context" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + coreruntime "github.com/initializ/forge/forge-core/runtime" +) + +func TestFileWatcher_DetectsChange(t *testing.T) { + dir := t.TempDir() + testFile := filepath.Join(dir, "main.py") + os.WriteFile(testFile, []byte("# original"), 0644) //nolint:errcheck + + var called atomic.Int32 + logger := coreruntime.NewJSONLogger(&bytes.Buffer{}, false) + + w := NewFileWatcher(dir, func() { + called.Add(1) + }, logger) + // Use shorter interval for testing + w.interval = 100 * time.Millisecond + w.debounce = 50 * time.Millisecond + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go w.Watch(ctx) + + // Wait for initial scan + time.Sleep(200 * time.Millisecond) + + // Modify the file + os.WriteFile(testFile, []byte("# modified"), 0644) //nolint:errcheck + + // Wait for detection + time.Sleep(500 * time.Millisecond) + + if called.Load() == 0 { + t.Error("onChange was not called after file modification") + } +} + +func TestFileWatcher_IgnoresHiddenDirs(t *testing.T) { + dir := t.TempDir() + + // Create a file in .git directory — should be ignored + gitDir := filepath.Join(dir, ".git") + os.MkdirAll(gitDir, 0755) //nolint:errcheck + os.WriteFile(filepath.Join(gitDir, "config.py"), []byte("x"), 0644) //nolint:errcheck + + // Create a watched file + os.WriteFile(filepath.Join(dir, "main.py"), []byte("ok"), 0644) //nolint:errcheck + + var called atomic.Int32 + logger := coreruntime.NewJSONLogger(&bytes.Buffer{}, false) + + w := NewFileWatcher(dir, func() { + called.Add(1) + }, logger) + w.interval = 100 * time.Millisecond + w.debounce = 50 * time.Millisecond + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go w.Watch(ctx) + time.Sleep(200 * time.Millisecond) + + // Modify .git file — should NOT trigger callback + os.WriteFile(filepath.Join(gitDir, "config.py"), []byte("modified"), 0644) //nolint:errcheck + time.Sleep(300 * time.Millisecond) + + if called.Load() != 0 { + t.Error("onChange should not be called for changes in .git directory") + } +} + +func TestFileWatcher_Debounce(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "app.py"), []byte("v1"), 0644) //nolint:errcheck + + var called atomic.Int32 + logger := coreruntime.NewJSONLogger(&bytes.Buffer{}, false) + + w := NewFileWatcher(dir, func() { + called.Add(1) + }, logger) + w.interval = 100 * time.Millisecond + w.debounce = 50 * time.Millisecond + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go w.Watch(ctx) + time.Sleep(200 * time.Millisecond) + + // Rapid changes should be debounced + for i := range 5 { + os.WriteFile(filepath.Join(dir, "app.py"), []byte("v"+string(rune('2'+i))), 0644) //nolint:errcheck + time.Sleep(20 * time.Millisecond) + } + + time.Sleep(500 * time.Millisecond) + + // The callback should have been called at least once + count := called.Load() + if count == 0 { + t.Error("expected at least one onChange call") + } +} diff --git a/forge-cli/server/a2a_server.go b/forge-cli/server/a2a_server.go new file mode 100644 index 0000000..a0260f7 --- /dev/null +++ b/forge-cli/server/a2a_server.go @@ -0,0 +1,195 @@ +package server + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net" + "net/http" + "sync" + + "github.com/initializ/forge/forge-core/a2a" +) + +// Handler processes a JSON-RPC request and returns a response. +type Handler func(ctx context.Context, id any, rawParams json.RawMessage) *a2a.JSONRPCResponse + +// SSEHandler streams SSE events for a JSON-RPC request. +type SSEHandler func(ctx context.Context, id any, rawParams json.RawMessage, w http.ResponseWriter, flusher http.Flusher) + +// ServerConfig configures the A2A HTTP server. +type ServerConfig struct { + Port int + AgentCard *a2a.AgentCard +} + +// Server is an A2A-compliant HTTP server with JSON-RPC 2.0 dispatch. +type Server struct { + port int + card *a2a.AgentCard + cardMu sync.RWMutex + store *a2a.TaskStore + handlers map[string]Handler + sseHandlers map[string]SSEHandler + srv *http.Server +} + +// NewServer creates a new A2A server. +func NewServer(cfg ServerConfig) *Server { + s := &Server{ + port: cfg.Port, + card: cfg.AgentCard, + store: a2a.NewTaskStore(), + handlers: make(map[string]Handler), + sseHandlers: make(map[string]SSEHandler), + } + return s +} + +// RegisterHandler registers a JSON-RPC method handler. +func (s *Server) RegisterHandler(method string, h Handler) { + s.handlers[method] = h +} + +// RegisterSSEHandler registers an SSE-streaming JSON-RPC method handler. +func (s *Server) RegisterSSEHandler(method string, h SSEHandler) { + s.sseHandlers[method] = h +} + +// UpdateAgentCard replaces the agent card (for hot-reload). +func (s *Server) UpdateAgentCard(card *a2a.AgentCard) { + s.cardMu.Lock() + defer s.cardMu.Unlock() + s.card = card +} + +// TaskStore returns the server's task store. +func (s *Server) TaskStore() *a2a.TaskStore { + return s.store +} + +func (s *Server) agentCard() *a2a.AgentCard { + s.cardMu.RLock() + defer s.cardMu.RUnlock() + return s.card +} + +// Start begins serving HTTP. It blocks until the context is cancelled or +// an error occurs. +func (s *Server) Start(ctx context.Context) error { + mux := http.NewServeMux() + mux.HandleFunc("GET /.well-known/agent.json", s.handleAgentCard) + mux.HandleFunc("GET /healthz", s.handleHealthz) + mux.HandleFunc("POST /", s.handleJSONRPC) + mux.HandleFunc("GET /", s.handleAgentCard) + + s.srv = &http.Server{ + Addr: fmt.Sprintf(":%d", s.port), + Handler: corsMiddleware(mux), + } + + ln, err := net.Listen("tcp", s.srv.Addr) + if err != nil { + return fmt.Errorf("listen on %s: %w", s.srv.Addr, err) + } + + go func() { + <-ctx.Done() + s.srv.Shutdown(context.Background()) //nolint:errcheck + }() + + if err := s.srv.Serve(ln); err != nil && err != http.ErrServerClosed { + return err + } + return nil +} + +// Shutdown gracefully shuts down the server. +func (s *Server) Shutdown(ctx context.Context) error { + if s.srv != nil { + return s.srv.Shutdown(ctx) + } + return nil +} + +func (s *Server) handleAgentCard(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(s.agentCard()) //nolint:errcheck +} + +func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"ok"}`)) //nolint:errcheck +} + +func (s *Server) handleJSONRPC(w http.ResponseWriter, r *http.Request) { + var req a2a.JSONRPCRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusOK, a2a.NewErrorResponse(nil, a2a.ErrCodeParseError, "parse error: "+err.Error())) + return + } + + if req.JSONRPC != "2.0" { + writeJSON(w, http.StatusOK, a2a.NewErrorResponse(req.ID, a2a.ErrCodeInvalidRequest, "jsonrpc must be \"2.0\"")) + return + } + + // Check SSE handlers first (for streaming methods) + if h, ok := s.sseHandlers[req.Method]; ok { + flusher, ok := w.(http.Flusher) + if !ok { + writeJSON(w, http.StatusOK, a2a.NewErrorResponse(req.ID, a2a.ErrCodeInternal, "streaming not supported")) + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + h(r.Context(), req.ID, req.Params, w, flusher) + return + } + + // Check regular handlers + if h, ok := s.handlers[req.Method]; ok { + resp := h(r.Context(), req.ID, req.Params) + writeJSON(w, http.StatusOK, resp) + return + } + + writeJSON(w, http.StatusOK, a2a.NewErrorResponse(req.ID, a2a.ErrCodeMethodNotFound, "method not found: "+req.Method)) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) //nolint:errcheck +} + +func corsMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} + +// WriteSSEEvent writes a single SSE event to the response writer. +func WriteSSEEvent(w http.ResponseWriter, flusher http.Flusher, event string, data any) error { + jsonData, err := json.Marshal(data) + if err != nil { + return err + } + _, _ = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, jsonData) + flusher.Flush() + return nil +} + +func init() { + // Suppress default log timestamp for cleaner output + log.SetFlags(0) +} diff --git a/forge-cli/skills/loader.go b/forge-cli/skills/loader.go new file mode 100644 index 0000000..d2fb649 --- /dev/null +++ b/forge-cli/skills/loader.go @@ -0,0 +1,27 @@ +package skills + +import ( + "os" + + coreskills "github.com/initializ/forge/forge-core/skills" +) + +// ParseFile reads a skills.md file and extracts structured SkillEntry values. +func ParseFile(path string) ([]coreskills.SkillEntry, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer func() { _ = f.Close() }() + return coreskills.Parse(f) +} + +// ParseFileWithMetadata reads a skills.md file and extracts entries with frontmatter metadata. +func ParseFileWithMetadata(path string) ([]coreskills.SkillEntry, *coreskills.SkillMetadata, error) { + f, err := os.Open(path) + if err != nil { + return nil, nil, err + } + defer func() { _ = f.Close() }() + return coreskills.ParseWithMetadata(f) +} diff --git a/forge-cli/skills/writer.go b/forge-cli/skills/writer.go new file mode 100644 index 0000000..e2155a0 --- /dev/null +++ b/forge-cli/skills/writer.go @@ -0,0 +1,37 @@ +package skills + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + coreskills "github.com/initializ/forge/forge-core/skills" +) + +// WriteArtifacts creates compiled/skills/skills.json and compiled/prompt.txt in outputDir. +func WriteArtifacts(outputDir string, cs *coreskills.CompiledSkills) error { + skillsDir := filepath.Join(outputDir, "compiled", "skills") + if err := os.MkdirAll(skillsDir, 0755); err != nil { + return fmt.Errorf("creating skills directory: %w", err) + } + + // Write skills.json + data, err := json.MarshalIndent(cs, "", " ") + if err != nil { + return fmt.Errorf("marshalling skills: %w", err) + } + skillsPath := filepath.Join(skillsDir, "skills.json") + if err := os.WriteFile(skillsPath, data, 0644); err != nil { + return fmt.Errorf("writing skills.json: %w", err) + } + + // Write prompt.txt + compiledDir := filepath.Join(outputDir, "compiled") + promptPath := filepath.Join(compiledDir, "prompt.txt") + if err := os.WriteFile(promptPath, []byte(cs.Prompt), 0644); err != nil { + return fmt.Errorf("writing prompt.txt: %w", err) + } + + return nil +} diff --git a/forge-cli/templates/Dockerfile.tmpl b/forge-cli/templates/Dockerfile.tmpl new file mode 100644 index 0000000..13e22bd --- /dev/null +++ b/forge-cli/templates/Dockerfile.tmpl @@ -0,0 +1,74 @@ +{{- if .Runtime.DepsFile}} +FROM {{.Runtime.Image}} AS deps +WORKDIR /app +COPY {{.Runtime.DepsFile}} . +{{- if eq .Runtime.DepsInstallCmd ""}} +RUN pip install --no-cache-dir -r {{.Runtime.DepsFile}} +{{- else}} +RUN {{.Runtime.DepsInstallCmd}} +{{- end}} +{{- end}} + +FROM {{.Runtime.Image}} + +LABEL org.opencontainers.image.title="{{.AgentID}}" \ + org.opencontainers.image.version="{{.Version}}" +{{- if .EgressProfile}} +LABEL ai.initializ.forge.egress-profile="{{.EgressProfile}}" \ + ai.initializ.forge.egress-mode="{{.EgressMode}}" +{{- end}} +{{- if .ToolInterfaceVersion}} +LABEL ai.initializ.forge.tool-interface-version="{{.ToolInterfaceVersion}}" +{{- end}} +{{- if gt .SkillsCount 0}} +LABEL ai.initializ.forge.skills-count="{{.SkillsCount}}" +{{- end}} +{{- if .DevBuild}} +LABEL ai.initializ.forge.dev-build="true" +{{- end}} +{{- if .ProdBuild}} +LABEL ai.initializ.forge.prod-build="true" +{{- end}} +{{- if .RequiredBins}} +# Required binaries (from skill requirements): +{{- range .RequiredBins}} +# - {{.}} +{{- end}} +{{- end}} + +{{- if .Runtime.ModelEnv}} +{{range $key, $val := .Runtime.ModelEnv}} +ENV {{$key}}="{{$val}}" +{{- end}} +{{- end}} + +ARG FORGE_DEV=false + +WORKDIR /app + +{{- if .Runtime.DepsFile}} +COPY --from=deps /app/ . +{{- end}} + +COPY . . + +{{- if .HasSkills}} +COPY compiled/skills/ /app/skills/ +COPY compiled/prompt.txt /app/compiled/prompt.txt +{{- end}} + +{{- if .Runtime.HealthCheck}} +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD {{.Runtime.HealthCheck}} +{{- end}} + +{{- if .Runtime.Port}} +EXPOSE {{.Runtime.Port}} +{{- end}} + +{{- if .Runtime.User}} +RUN useradd -r -s /bin/false agent && chown -R agent:agent /app +USER agent +{{- end}} + +ENTRYPOINT {{.Runtime.Entrypoint}} diff --git a/forge-cli/templates/deployment.yaml.tmpl b/forge-cli/templates/deployment.yaml.tmpl new file mode 100644 index 0000000..8c55511 --- /dev/null +++ b/forge-cli/templates/deployment.yaml.tmpl @@ -0,0 +1,61 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{.AgentID}} + labels: + app: {{.AgentID}} + forge.initializ.ai/version: "{{.Version}}" + {{- if .EgressProfile}} + forge.initializ.ai/egress-profile: "{{.EgressProfile}}" + {{- end}} +spec: + replicas: 1 + selector: + matchLabels: + app: {{.AgentID}} + template: + metadata: + labels: + app: {{.AgentID}} + {{- if .EgressProfile}} + forge.initializ.ai/egress-profile: "{{.EgressProfile}}" + {{- end}} + spec: + containers: + - name: {{.AgentID}} + image: {{.Runtime.Image}} + {{- if .Runtime.Port}} + ports: + - containerPort: {{.Runtime.Port}} + {{- end}} + {{- if .Runtime.Env}} + env: + {{- range $key, $val := .Runtime.Env}} + - name: {{$key}} + value: "{{$val}}" + {{- end}} + - name: FORGE_API_KEY + valueFrom: + secretKeyRef: + name: {{.AgentID}}-secrets + key: api-key + optional: true + - name: FORGE_BASE_URL + valueFrom: + secretKeyRef: + name: {{.AgentID}}-secrets + key: base-url + optional: true + - name: FORGE_TEMPERATURE + valueFrom: + secretKeyRef: + name: {{.AgentID}}-secrets + key: temperature + optional: true + - name: FORGE_MAX_TOKENS + valueFrom: + secretKeyRef: + name: {{.AgentID}}-secrets + key: max-tokens + optional: true + {{- end}} diff --git a/forge-cli/templates/docker-compose.yaml.tmpl b/forge-cli/templates/docker-compose.yaml.tmpl new file mode 100644 index 0000000..319ebe9 --- /dev/null +++ b/forge-cli/templates/docker-compose.yaml.tmpl @@ -0,0 +1,35 @@ +version: "3.8" +services: + agent: + image: {{.ImageTag}} + ports: + - "{{.Port}}:{{.Port}}" + environment: +{{- if .ModelProvider}} + - FORGE_PROVIDER={{.ModelProvider}} +{{- end}} +{{- if .ModelName}} + - FORGE_MODEL={{.ModelName}} +{{- end}} + - FORGE_API_KEY=${FORGE_API_KEY} +{{- if or .EgressProfile .EgressMode}} + labels: +{{- if .EgressProfile}} + forge.egress.profile: {{.EgressProfile}} +{{- end}} +{{- if .EgressMode}} + forge.egress.mode: {{.EgressMode}} +{{- end}} +{{- end}} +{{range .Channels}} + {{.Name}}-adapter: + image: {{$.ImageTag}} + command: ["forge", "channel", "serve", "{{.Name}}"] + environment: + - AGENT_URL=http://agent:{{$.Port}} +{{- range .EnvVars}} + - {{.}} +{{- end}} + depends_on: + - agent +{{end}} diff --git a/forge-cli/templates/embed.go b/forge-cli/templates/embed.go new file mode 100644 index 0000000..16259b4 --- /dev/null +++ b/forge-cli/templates/embed.go @@ -0,0 +1,25 @@ +// Package templates provides embedded template files for forge. +package templates + +import "embed" + +//go:embed Dockerfile.tmpl deployment.yaml.tmpl service.yaml.tmpl network-policy.yaml.tmpl secrets.yaml.tmpl docker-compose.yaml.tmpl init wrapper +var FS embed.FS + +// GetInitTemplate reads a template file from the init directory. +func GetInitTemplate(path string) (string, error) { + data, err := FS.ReadFile("init/" + path) + if err != nil { + return "", err + } + return string(data), nil +} + +// GetWrapperTemplate reads a template file from the wrapper directory. +func GetWrapperTemplate(name string) (string, error) { + data, err := FS.ReadFile("wrapper/" + name) + if err != nil { + return "", err + } + return string(data), nil +} diff --git a/forge-cli/templates/init/crewai/agent.py.tmpl b/forge-cli/templates/init/crewai/agent.py.tmpl new file mode 100644 index 0000000..aae304d --- /dev/null +++ b/forge-cli/templates/init/crewai/agent.py.tmpl @@ -0,0 +1,38 @@ +"""{{.Name}} - A CrewAI-based agent.""" + +from crewai import Agent, Crew, Task +from tools.example_tool import ExampleTool + + +def create_agent(): + """Create and return the CrewAI agent.""" + agent = Agent( + role="{{.Name}}", + goal="Accomplish tasks using available tools", + backstory="You are a helpful AI assistant.", + tools=[ExampleTool()], + verbose=True, + ) + return agent + + +def create_crew(agent): + """Create a crew with the agent.""" + task = Task( + description="Process the user request.", + expected_output="A helpful response.", + agent=agent, + ) + crew = Crew( + agents=[agent], + tasks=[task], + verbose=True, + ) + return crew + + +if __name__ == "__main__": + agent = create_agent() + crew = create_crew(agent) + result = crew.kickoff() + print(result) diff --git a/forge-cli/templates/init/crewai/example_tool.py.tmpl b/forge-cli/templates/init/crewai/example_tool.py.tmpl new file mode 100644 index 0000000..7f17373 --- /dev/null +++ b/forge-cli/templates/init/crewai/example_tool.py.tmpl @@ -0,0 +1,12 @@ +"""Example tool for {{.Name}}.""" + +from crewai.tools import BaseTool + + +class ExampleTool(BaseTool): + name: str = "example_tool" + description: str = "An example tool that processes text input." + + def _run(self, query: str) -> str: + """Execute the tool with the given query.""" + return f"Processed: {query}" diff --git a/forge-cli/templates/init/custom/agent.py.tmpl b/forge-cli/templates/init/custom/agent.py.tmpl new file mode 100644 index 0000000..eedc06c --- /dev/null +++ b/forge-cli/templates/init/custom/agent.py.tmpl @@ -0,0 +1,56 @@ +"""{{.Name}} - A custom A2A agent.""" + +import json +from http.server import HTTPServer, BaseHTTPRequestHandler + + +class AgentHandler(BaseHTTPRequestHandler): + """Simple A2A-compatible HTTP handler.""" + + def do_POST(self): + content_length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(content_length) + request = json.loads(body) if body else {} + + # Process the request + response = { + "id": request.get("id", ""), + "status": {"state": "completed"}, + "artifacts": [ + { + "parts": [ + {"kind": "text", "text": "Hello from {{.Name}}!"} + ] + } + ], + } + + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps(response).encode()) + + def do_GET(self): + """Return the agent card.""" + card = { + "name": "{{.Name}}", + "description": "A custom A2A agent", + "url": "http://localhost:8080", + "skills": [ + { + "id": "default", + "name": "Default Skill", + "description": "Default agent capability", + } + ], + } + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps(card).encode()) + + +if __name__ == "__main__": + server = HTTPServer(("0.0.0.0", 8080), AgentHandler) + print("{{.Name}} listening on :8080") + server.serve_forever() diff --git a/forge-cli/templates/init/custom/agent.ts.tmpl b/forge-cli/templates/init/custom/agent.ts.tmpl new file mode 100644 index 0000000..e456283 --- /dev/null +++ b/forge-cli/templates/init/custom/agent.ts.tmpl @@ -0,0 +1,44 @@ +/** + * {{.Name}} - A custom A2A agent (TypeScript). + */ + +const server = Bun.serve({ + port: 8080, + async fetch(req: Request): Promise { + const url = new URL(req.url); + + if (req.method === "GET" && url.pathname === "/") { + const card = { + name: "{{.Name}}", + description: "A custom A2A agent", + url: "http://localhost:8080", + skills: [ + { + id: "default", + name: "Default Skill", + description: "Default agent capability", + }, + ], + }; + return Response.json(card); + } + + if (req.method === "POST") { + const body = await req.json(); + const response = { + id: body.id || "", + status: { state: "completed" }, + artifacts: [ + { + parts: [{ kind: "text", text: "Hello from {{.Name}}!" }], + }, + ], + }; + return Response.json(response); + } + + return new Response("Not Found", { status: 404 }); + }, +}); + +console.log(`{{.Name}} listening on :${server.port}`); diff --git a/forge-cli/templates/init/custom/example_tool.go.tmpl b/forge-cli/templates/init/custom/example_tool.go.tmpl new file mode 100644 index 0000000..21b77c7 --- /dev/null +++ b/forge-cli/templates/init/custom/example_tool.go.tmpl @@ -0,0 +1,8 @@ +package tools + +import "fmt" + +// ExampleTool processes a text query and returns a result. +func ExampleTool(query string) string { + return fmt.Sprintf("Processed: %s", query) +} diff --git a/forge-cli/templates/init/custom/example_tool.py.tmpl b/forge-cli/templates/init/custom/example_tool.py.tmpl new file mode 100644 index 0000000..9448034 --- /dev/null +++ b/forge-cli/templates/init/custom/example_tool.py.tmpl @@ -0,0 +1,6 @@ +"""Example tool for {{.Name}}.""" + + +def example_tool(query: str) -> str: + """An example tool that processes text input.""" + return f"Processed: {query}" diff --git a/forge-cli/templates/init/custom/example_tool.ts.tmpl b/forge-cli/templates/init/custom/example_tool.ts.tmpl new file mode 100644 index 0000000..5ebf8ea --- /dev/null +++ b/forge-cli/templates/init/custom/example_tool.ts.tmpl @@ -0,0 +1,7 @@ +/** + * Example tool for {{.Name}}. + */ + +export function exampleTool(query: string): string { + return `Processed: ${query}`; +} diff --git a/forge-cli/templates/init/custom/main.go.tmpl b/forge-cli/templates/init/custom/main.go.tmpl new file mode 100644 index 0000000..0b7e689 --- /dev/null +++ b/forge-cli/templates/init/custom/main.go.tmpl @@ -0,0 +1,89 @@ +// {{.Name}} - A custom A2A agent (Go). +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" +) + +type part struct { + Kind string ` + "`" + `json:"kind"` + "`" + ` + Text string ` + "`" + `json:"text,omitempty"` + "`" + ` +} + +type artifact struct { + Parts []part ` + "`" + `json:"parts"` + "`" + ` +} + +type taskStatus struct { + State string ` + "`" + `json:"state"` + "`" + ` +} + +type taskResponse struct { + ID string ` + "`" + `json:"id"` + "`" + ` + Status taskStatus ` + "`" + `json:"status"` + "`" + ` + Artifacts []artifact ` + "`" + `json:"artifacts"` + "`" + ` +} + +type skill struct { + ID string ` + "`" + `json:"id"` + "`" + ` + Name string ` + "`" + `json:"name"` + "`" + ` + Description string ` + "`" + `json:"description"` + "`" + ` +} + +type agentCard struct { + Name string ` + "`" + `json:"name"` + "`" + ` + Description string ` + "`" + `json:"description"` + "`" + ` + URL string ` + "`" + `json:"url"` + "`" + ` + Skills []skill ` + "`" + `json:"skills"` + "`" + ` +} + +func main() { + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if r.Method == http.MethodGet { + card := agentCard{ + Name: "{{.Name}}", + Description: "A custom A2A agent", + URL: "http://localhost:8080", + Skills: []skill{ + { + ID: "default", + Name: "Default Skill", + Description: "Default agent capability", + }, + }, + } + json.NewEncoder(w).Encode(card) + return + } + + var req map[string]interface{} + json.NewDecoder(r.Body).Decode(&req) + + id, _ := req["id"].(string) + resp := taskResponse{ + ID: id, + Status: taskStatus{ + State: "completed", + }, + Artifacts: []artifact{ + { + Parts: []part{ + { + Kind: "text", + Text: "Hello from {{.Name}}!", + }, + }, + }, + }, + } + json.NewEncoder(w).Encode(resp) + }) + + fmt.Println("{{.Name}} listening on :8080") + log.Fatal(http.ListenAndServe(":8080", nil)) +} diff --git a/forge-cli/templates/init/env-slack.tmpl b/forge-cli/templates/init/env-slack.tmpl new file mode 100644 index 0000000..e9affa4 --- /dev/null +++ b/forge-cli/templates/init/env-slack.tmpl @@ -0,0 +1,4 @@ + +# Slack channel adapter +SLACK_SIGNING_SECRET= +SLACK_BOT_TOKEN= diff --git a/forge-cli/templates/init/env-telegram.tmpl b/forge-cli/templates/init/env-telegram.tmpl new file mode 100644 index 0000000..447c7b5 --- /dev/null +++ b/forge-cli/templates/init/env-telegram.tmpl @@ -0,0 +1,3 @@ + +# Telegram channel adapter +TELEGRAM_BOT_TOKEN= diff --git a/forge-cli/templates/init/env.example.tmpl b/forge-cli/templates/init/env.example.tmpl new file mode 100644 index 0000000..c804947 --- /dev/null +++ b/forge-cli/templates/init/env.example.tmpl @@ -0,0 +1,13 @@ +# {{.Name}} Environment Variables +{{- if eq .ModelProvider "openai"}} +OPENAI_API_KEY=your-api-key-here +{{- else if eq .ModelProvider "anthropic"}} +ANTHROPIC_API_KEY=your-api-key-here +{{- else if eq .ModelProvider "gemini"}} +GEMINI_API_KEY=your-api-key-here +{{- else if eq .ModelProvider "ollama"}} +OLLAMA_HOST=http://localhost:11434 +{{- else}} +# Add your model provider API key here +MODEL_API_KEY=your-api-key-here +{{- end}} diff --git a/forge-cli/templates/init/forge.yaml.tmpl b/forge-cli/templates/init/forge.yaml.tmpl new file mode 100644 index 0000000..b85e739 --- /dev/null +++ b/forge-cli/templates/init/forge.yaml.tmpl @@ -0,0 +1,45 @@ +agent_id: {{.AgentID}} +version: 0.1.0 +framework: {{.Framework}} +entrypoint: {{.Entrypoint}} + +model: + provider: {{.ModelProvider}} + name: {{.ModelName}} + version: "latest" +{{- if .Tools}} + +tools: +{{- range .Tools}} + - name: {{.Name}} + type: {{.Type}} +{{- end}} +{{- end}} +{{- if .Channels}} + +channels: +{{- range .Channels}} + - {{.}} +{{- end}} +{{- end}} +{{- if .BuiltinTools}} + +builtin_tools: +{{- range .BuiltinTools}} + - {{.}} +{{- end}} +{{- end}} +{{- if .SkillEntries}} + +skills: + path: skills.md +{{- end}} +{{- if .EgressDomains}} + +egress: + mode: allowlist + allowed_domains: +{{- range .EgressDomains}} + - {{.}} +{{- end}} +{{- end}} diff --git a/forge-cli/templates/init/gitignore.tmpl b/forge-cli/templates/init/gitignore.tmpl new file mode 100644 index 0000000..d66b707 --- /dev/null +++ b/forge-cli/templates/init/gitignore.tmpl @@ -0,0 +1,8 @@ +.env +__pycache__/ +*.pyc +node_modules/ +dist/ +.venv/ +venv/ +*.log diff --git a/forge-cli/templates/init/langchain/agent.py.tmpl b/forge-cli/templates/init/langchain/agent.py.tmpl new file mode 100644 index 0000000..87f3b89 --- /dev/null +++ b/forge-cli/templates/init/langchain/agent.py.tmpl @@ -0,0 +1,40 @@ +"""{{.Name}} - A LangChain-based agent.""" + +from langchain.agents import AgentExecutor, create_tool_calling_agent +from langchain_core.prompts import ChatPromptTemplate +{{- if eq .ModelProvider "openai"}} +from langchain_openai import ChatOpenAI +{{- else if eq .ModelProvider "anthropic"}} +from langchain_anthropic import ChatAnthropic +{{- else}} +from langchain_openai import ChatOpenAI +{{- end}} +from tools.example_tool import example_tool + + +def create_agent(): + """Create and return the LangChain agent.""" +{{- if eq .ModelProvider "openai"}} + llm = ChatOpenAI(model="{{.ModelName}}") +{{- else if eq .ModelProvider "anthropic"}} + llm = ChatAnthropic(model="{{.ModelName}}") +{{- else}} + llm = ChatOpenAI(model="{{.ModelName}}") +{{- end}} + + tools = [example_tool] + + prompt = ChatPromptTemplate.from_messages([ + ("system", "You are a helpful AI assistant."), + ("human", "{input}"), + ("placeholder", "{agent_scratchpad}"), + ]) + + agent = create_tool_calling_agent(llm, tools, prompt) + return AgentExecutor(agent=agent, tools=tools, verbose=True) + + +if __name__ == "__main__": + executor = create_agent() + result = executor.invoke({"input": "Hello, what can you do?"}) + print(result["output"]) diff --git a/forge-cli/templates/init/langchain/example_tool.py.tmpl b/forge-cli/templates/init/langchain/example_tool.py.tmpl new file mode 100644 index 0000000..ca39612 --- /dev/null +++ b/forge-cli/templates/init/langchain/example_tool.py.tmpl @@ -0,0 +1,9 @@ +"""Example tool for {{.Name}}.""" + +from langchain_core.tools import tool + + +@tool +def example_tool(query: str) -> str: + """An example tool that processes text input.""" + return f"Processed: {query}" diff --git a/forge-cli/templates/init/skills.md.tmpl b/forge-cli/templates/init/skills.md.tmpl new file mode 100644 index 0000000..3065d8c --- /dev/null +++ b/forge-cli/templates/init/skills.md.tmpl @@ -0,0 +1,19 @@ +# {{.Name}} Skills + +## Tool: example_tool + +A sample tool that demonstrates tool integration. + +**Input:** A text query. + +**Output:** The processed result. +{{- range .Tools}} + +## Tool: {{.Name}} + +Description for {{.Name}}. + +**Input:** TBD + +**Output:** TBD +{{- end}} diff --git a/forge-cli/templates/init/slack-config.yaml.tmpl b/forge-cli/templates/init/slack-config.yaml.tmpl new file mode 100644 index 0000000..d9f7ac3 --- /dev/null +++ b/forge-cli/templates/init/slack-config.yaml.tmpl @@ -0,0 +1,6 @@ +adapter: slack +webhook_port: 3000 +webhook_path: /slack/events +settings: + signing_secret_env: SLACK_SIGNING_SECRET + bot_token_env: SLACK_BOT_TOKEN diff --git a/forge-cli/templates/init/telegram-config.yaml.tmpl b/forge-cli/templates/init/telegram-config.yaml.tmpl new file mode 100644 index 0000000..c5341c3 --- /dev/null +++ b/forge-cli/templates/init/telegram-config.yaml.tmpl @@ -0,0 +1,6 @@ +adapter: telegram +webhook_port: 3001 +webhook_path: /telegram/webhook +settings: + bot_token_env: TELEGRAM_BOT_TOKEN + mode: polling diff --git a/forge-cli/templates/network-policy.yaml.tmpl b/forge-cli/templates/network-policy.yaml.tmpl new file mode 100644 index 0000000..8ae0355 --- /dev/null +++ b/forge-cli/templates/network-policy.yaml.tmpl @@ -0,0 +1,23 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{.AgentID}}-network + labels: + app: {{.AgentID}} +spec: + podSelector: + matchLabels: + app: {{.AgentID}} + policyTypes: + - Egress + {{- if .NetworkPolicy.DenyAll}} + egress: [] + {{- else}} + egress: + - to: [] + ports: + - protocol: TCP + port: 443 + - protocol: TCP + port: 80 + {{- end}} diff --git a/forge-cli/templates/secrets.yaml.tmpl b/forge-cli/templates/secrets.yaml.tmpl new file mode 100644 index 0000000..ab0c773 --- /dev/null +++ b/forge-cli/templates/secrets.yaml.tmpl @@ -0,0 +1,23 @@ +apiVersion: v1 +kind: Secret +metadata: + name: {{.AgentID}}-secrets + labels: + app: {{.AgentID}} + {{- if .RequiredEnvVars}} + annotations: + forge.initializ.ai/generated: "true" + {{- end}} +type: Opaque +stringData: + api-key: "" + base-url: "" + temperature: "" + max-tokens: "" +{{- range .RequiredEnvVars}} + {{.}}: "" +{{- end}} +{{- range .OptionalEnvVars}} + # optional + {{.}}: "" +{{- end}} diff --git a/forge-cli/templates/service.yaml.tmpl b/forge-cli/templates/service.yaml.tmpl new file mode 100644 index 0000000..a74baf1 --- /dev/null +++ b/forge-cli/templates/service.yaml.tmpl @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{.AgentID}} + labels: + app: {{.AgentID}} +spec: + selector: + app: {{.AgentID}} + ports: + - protocol: TCP + port: 80 + targetPort: {{.Runtime.Port}} + type: ClusterIP diff --git a/forge-cli/templates/wrapper/crewai_wrapper.py.tmpl b/forge-cli/templates/wrapper/crewai_wrapper.py.tmpl new file mode 100644 index 0000000..c4962e4 --- /dev/null +++ b/forge-cli/templates/wrapper/crewai_wrapper.py.tmpl @@ -0,0 +1,120 @@ +""" +A2A-compliant HTTP wrapper for CrewAI agents. +Auto-generated by Forge — do not edit manually. +""" +import json +import os +import sys +import uuid +from http.server import HTTPServer, BaseHTTPRequestHandler + +# Ensure the project root is importable +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from agent import create_agent, create_crew # noqa: E402 + +AGENT_NAME = "{{.Name}}" +AGENT_DESCRIPTION = "{{.Description}}" +SKILLS = [ +{{- range .Tools}} + {"id": "{{.Name}}", "name": "{{.Name}}", "description": "{{.Description}}"}, +{{- end}} +] + + +def build_agent_card(): + return { + "name": AGENT_NAME, + "description": AGENT_DESCRIPTION, + "url": f"http://localhost:{os.environ.get('PORT', '8080')}", + "skills": SKILLS, + "capabilities": { + "streaming": False, + "pushNotifications": False, + "stateTransitionHistory": False, + }, + } + + +class A2AHandler(BaseHTTPRequestHandler): + def do_GET(self): + """Return agent card at root.""" + if self.path == "/" or self.path == "/.well-known/agent.json": + card = build_agent_card() + payload = json.dumps(card).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + else: + self.send_error(404) + + def do_POST(self): + """Handle A2A task requests.""" + try: + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length)) if length else {} + + # Extract input text from A2A message + input_text = "" + params = body.get("params", {}) + message = params.get("message", {}) + parts = message.get("parts", []) + for part in parts: + if part.get("kind") == "text": + input_text = part.get("text", "") + break + + task_id = params.get("id", str(uuid.uuid4())) + + # Run CrewAI crew + crew = create_crew() + result = crew.kickoff(inputs={"input": input_text}) + + response = { + "jsonrpc": "2.0", + "id": body.get("id"), + "result": { + "id": task_id, + "status": {"state": "completed"}, + "artifacts": [ + { + "parts": [{"kind": "text", "text": str(result)}] + } + ], + }, + } + except Exception as exc: + response = { + "jsonrpc": "2.0", + "id": body.get("id") if "body" in dir() else None, + "result": { + "id": task_id if "task_id" in dir() else str(uuid.uuid4()), + "status": { + "state": "failed", + "message": {"role": "agent", "parts": [{"kind": "text", "text": str(exc)}]}, + }, + }, + } + + payload = json.dumps(response).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, fmt, *args): + print(f"[A2A] {fmt % args}") + + +def main(): + port = int(os.environ.get("PORT", "8080")) + server = HTTPServer(("0.0.0.0", port), A2AHandler) + print(f"CrewAI A2A wrapper listening on port {port}") + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/forge-cli/templates/wrapper/langchain_wrapper.py.tmpl b/forge-cli/templates/wrapper/langchain_wrapper.py.tmpl new file mode 100644 index 0000000..927e85e --- /dev/null +++ b/forge-cli/templates/wrapper/langchain_wrapper.py.tmpl @@ -0,0 +1,121 @@ +""" +A2A-compliant HTTP wrapper for LangChain agents. +Auto-generated by Forge — do not edit manually. +""" +import json +import os +import sys +import uuid +from http.server import HTTPServer, BaseHTTPRequestHandler + +# Ensure the project root is importable +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from agent import create_agent # noqa: E402 + +AGENT_NAME = "{{.Name}}" +AGENT_DESCRIPTION = "{{.Description}}" +SKILLS = [ +{{- range .Tools}} + {"id": "{{.Name}}", "name": "{{.Name}}", "description": "{{.Description}}"}, +{{- end}} +] + + +def build_agent_card(): + return { + "name": AGENT_NAME, + "description": AGENT_DESCRIPTION, + "url": f"http://localhost:{os.environ.get('PORT', '8080')}", + "skills": SKILLS, + "capabilities": { + "streaming": False, + "pushNotifications": False, + "stateTransitionHistory": False, + }, + } + + +class A2AHandler(BaseHTTPRequestHandler): + def do_GET(self): + """Return agent card at root.""" + if self.path == "/" or self.path == "/.well-known/agent.json": + card = build_agent_card() + payload = json.dumps(card).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + else: + self.send_error(404) + + def do_POST(self): + """Handle A2A task requests.""" + try: + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length)) if length else {} + + # Extract input text from A2A message + input_text = "" + params = body.get("params", {}) + message = params.get("message", {}) + parts = message.get("parts", []) + for part in parts: + if part.get("kind") == "text": + input_text = part.get("text", "") + break + + task_id = params.get("id", str(uuid.uuid4())) + + # Run LangChain agent + executor = create_agent() + result = executor.invoke({"input": input_text}) + output_text = result.get("output", str(result)) if isinstance(result, dict) else str(result) + + response = { + "jsonrpc": "2.0", + "id": body.get("id"), + "result": { + "id": task_id, + "status": {"state": "completed"}, + "artifacts": [ + { + "parts": [{"kind": "text", "text": output_text}] + } + ], + }, + } + except Exception as exc: + response = { + "jsonrpc": "2.0", + "id": body.get("id") if "body" in dir() else None, + "result": { + "id": task_id if "task_id" in dir() else str(uuid.uuid4()), + "status": { + "state": "failed", + "message": {"role": "agent", "parts": [{"kind": "text", "text": str(exc)}]}, + }, + }, + } + + payload = json.dumps(response).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, fmt, *args): + print(f"[A2A] {fmt % args}") + + +def main(): + port = int(os.environ.get("PORT", "8080")) + server = HTTPServer(("0.0.0.0", port), A2AHandler) + print(f"LangChain A2A wrapper listening on port {port}") + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/forge-cli/tools/cli_execute.go b/forge-cli/tools/cli_execute.go new file mode 100644 index 0000000..fed4f0d --- /dev/null +++ b/forge-cli/tools/cli_execute.go @@ -0,0 +1,322 @@ +package tools + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + "time" + + coretools "github.com/initializ/forge/forge-core/tools" +) + +// CLIExecuteConfig holds the configuration for the cli_execute tool. +type CLIExecuteConfig struct { + AllowedBinaries []string + EnvPassthrough []string + TimeoutSeconds int // default 120 + MaxOutputBytes int // default 1MB +} + +// CLIExecuteTool is a Category-A builtin tool that executes only pre-approved +// CLI binaries via exec.Command (no shell), with env isolation, timeouts, and +// output limits. +type CLIExecuteTool struct { + config CLIExecuteConfig + allowedSet map[string]bool // O(1) allowlist lookup + binaryPaths map[string]string // resolved absolute paths from exec.LookPath + available []string + missing []string +} + +// cliExecuteArgs is the JSON input schema for Execute. +type cliExecuteArgs struct { + Binary string `json:"binary"` + Args []string `json:"args"` + Stdin string `json:"stdin,omitempty"` +} + +// cliExecuteResult is the JSON output format matching local_shell pattern. +type cliExecuteResult struct { + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + ExitCode int `json:"exit_code"` + Truncated bool `json:"truncated"` +} + +// NewCLIExecuteTool creates a CLIExecuteTool from the given config. +// It resolves each binary via exec.LookPath at startup and records availability. +func NewCLIExecuteTool(config CLIExecuteConfig) *CLIExecuteTool { + if config.TimeoutSeconds <= 0 { + config.TimeoutSeconds = 120 + } + if config.MaxOutputBytes <= 0 { + config.MaxOutputBytes = 1048576 // 1MB + } + + t := &CLIExecuteTool{ + config: config, + allowedSet: make(map[string]bool, len(config.AllowedBinaries)), + binaryPaths: make(map[string]string, len(config.AllowedBinaries)), + } + + for _, bin := range config.AllowedBinaries { + t.allowedSet[bin] = true + absPath, err := exec.LookPath(bin) + if err != nil { + t.missing = append(t.missing, bin) + } else { + t.binaryPaths[bin] = absPath + t.available = append(t.available, bin) + } + } + + return t +} + +// Name returns the tool name. +func (t *CLIExecuteTool) Name() string { return "cli_execute" } + +// Category returns CategoryBuiltin. +func (t *CLIExecuteTool) Category() coretools.Category { return coretools.CategoryBuiltin } + +// Description returns a dynamic description listing available binaries. +func (t *CLIExecuteTool) Description() string { + if len(t.available) == 0 { + return "Execute pre-approved CLI binaries (none available)" + } + return fmt.Sprintf("Execute pre-approved CLI binaries: %s", strings.Join(t.available, ", ")) +} + +// InputSchema returns a dynamic JSON schema with the binary field's enum +// populated from AllowedBinaries. +func (t *CLIExecuteTool) InputSchema() json.RawMessage { + // Build enum array for binary field + enumItems := make([]string, 0, len(t.config.AllowedBinaries)) + for _, bin := range t.config.AllowedBinaries { + enumItems = append(enumItems, fmt.Sprintf("%q", bin)) + } + + schema := fmt.Sprintf(`{ + "type": "object", + "properties": { + "binary": { + "type": "string", + "description": "The binary to execute (must be from the allowed list)", + "enum": [%s] + }, + "args": { + "type": "array", + "items": {"type": "string"}, + "description": "Command-line arguments to pass to the binary" + }, + "stdin": { + "type": "string", + "description": "Optional stdin input to pipe to the process" + } + }, + "required": ["binary"] +}`, strings.Join(enumItems, ", ")) + + return json.RawMessage(schema) +} + +// Execute runs the specified binary with the given arguments after performing +// all security checks. +func (t *CLIExecuteTool) Execute(ctx context.Context, args json.RawMessage) (string, error) { + var input cliExecuteArgs + if err := json.Unmarshal(args, &input); err != nil { + return "", fmt.Errorf("cli_execute: invalid arguments: %w", err) + } + + // Security check 1: Binary allowlist + if !t.allowedSet[input.Binary] { + return "", fmt.Errorf("cli_execute: binary %q is not in the allowed list", input.Binary) + } + + // Security check 2: Binary availability + absPath, ok := t.binaryPaths[input.Binary] + if !ok { + return "", fmt.Errorf("cli_execute: binary %q was not found on this system", input.Binary) + } + + // Security check 3: Arg validation (defense-in-depth) + for i, arg := range input.Args { + if err := validateArg(arg); err != nil { + return "", fmt.Errorf("cli_execute: argument %d: %w", i, err) + } + } + + // Security check 4: Timeout + timeout := time.Duration(t.config.TimeoutSeconds) * time.Second + cmdCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + // Security check 5: No shell — exec.CommandContext directly + cmd := exec.CommandContext(cmdCtx, absPath, input.Args...) + + // Security check 6: Env isolation + cmd.Env = t.buildEnv() + + // Stdin + if input.Stdin != "" { + cmd.Stdin = strings.NewReader(input.Stdin) + } + + // Security check 7: Output limit + stdoutWriter := newLimitedWriter(t.config.MaxOutputBytes) + stderrWriter := newLimitedWriter(t.config.MaxOutputBytes) + cmd.Stdout = stdoutWriter + cmd.Stderr = stderrWriter + + // Run the command + exitCode := 0 + err := cmd.Run() + if err != nil { + if cmdCtx.Err() == context.DeadlineExceeded { + return "", fmt.Errorf("cli_execute: command timed out after %ds", t.config.TimeoutSeconds) + } + // Extract exit code from ExitError + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } else { + return "", fmt.Errorf("cli_execute: failed to run command: %w", err) + } + } + + // Build result + result := cliExecuteResult{ + Stdout: stdoutWriter.String(), + Stderr: stderrWriter.String(), + ExitCode: exitCode, + Truncated: stdoutWriter.overflow || stderrWriter.overflow, + } + + resultJSON, err := json.Marshal(result) + if err != nil { + return "", fmt.Errorf("cli_execute: failed to marshal result: %w", err) + } + + return string(resultJSON), nil +} + +// Availability returns the lists of available and missing binaries. +func (t *CLIExecuteTool) Availability() (available, missing []string) { + return t.available, t.missing +} + +// buildEnv constructs an isolated environment with only PATH, HOME, LANG +// and explicitly configured passthrough variables. +func (t *CLIExecuteTool) buildEnv() []string { + env := []string{ + "PATH=" + os.Getenv("PATH"), + "HOME=" + os.Getenv("HOME"), + "LANG=" + os.Getenv("LANG"), + } + for _, key := range t.config.EnvPassthrough { + if val, ok := os.LookupEnv(key); ok { + env = append(env, key+"="+val) + } + } + return env +} + +// validateArg rejects arguments containing shell injection patterns. +// Since we use exec.Command (no shell), these are defense-in-depth checks +// against confused upstream processing. +func validateArg(arg string) error { + if strings.Contains(arg, "$(") { + return fmt.Errorf("argument contains command substitution '$(': %q", arg) + } + if strings.Contains(arg, "`") { + return fmt.Errorf("argument contains backtick: %q", arg) + } + if strings.ContainsAny(arg, "\n\r") { + return fmt.Errorf("argument contains newline: %q", arg) + } + return nil +} + +// ParseCLIExecuteConfig extracts typed config from the map[string]any that +// YAML produces. Handles both int and float64 for numeric fields. +func ParseCLIExecuteConfig(raw map[string]any) CLIExecuteConfig { + cfg := CLIExecuteConfig{} + + if bins, ok := raw["allowed_binaries"]; ok { + if binSlice, ok := bins.([]any); ok { + for _, b := range binSlice { + if s, ok := b.(string); ok { + cfg.AllowedBinaries = append(cfg.AllowedBinaries, s) + } + } + } + } + + if envPass, ok := raw["env_passthrough"]; ok { + if envSlice, ok := envPass.([]any); ok { + for _, e := range envSlice { + if s, ok := e.(string); ok { + cfg.EnvPassthrough = append(cfg.EnvPassthrough, s) + } + } + } + } + + if timeout, ok := raw["timeout"]; ok { + cfg.TimeoutSeconds = toInt(timeout) + } + + if maxOutput, ok := raw["max_output_bytes"]; ok { + cfg.MaxOutputBytes = toInt(maxOutput) + } + + return cfg +} + +// toInt converts a numeric value from YAML/JSON (may be int or float64) to int. +func toInt(v any) int { + switch n := v.(type) { + case int: + return n + case float64: + return int(n) + case int64: + return int(n) + default: + return 0 + } +} + +// limitedWriter wraps a bytes.Buffer and silently drops bytes after the limit. +// It always returns len(p) to avoid broken pipe errors from subprocesses. +type limitedWriter struct { + buf bytes.Buffer + limit int + overflow bool +} + +func newLimitedWriter(limit int) *limitedWriter { + return &limitedWriter{limit: limit} +} + +func (w *limitedWriter) Write(p []byte) (int, error) { + remaining := w.limit - w.buf.Len() + if remaining <= 0 { + w.overflow = true + return len(p), nil + } + if len(p) > remaining { + w.buf.Write(p[:remaining]) + w.overflow = true + return len(p), nil + } + w.buf.Write(p) + return len(p), nil +} + +func (w *limitedWriter) String() string { + return w.buf.String() +} diff --git a/forge-cli/tools/cli_execute_test.go b/forge-cli/tools/cli_execute_test.go new file mode 100644 index 0000000..84282a6 --- /dev/null +++ b/forge-cli/tools/cli_execute_test.go @@ -0,0 +1,356 @@ +package tools + +import ( + "context" + "encoding/json" + "runtime" + "strings" + "testing" +) + +func TestCLIExecute_Name(t *testing.T) { + tool := NewCLIExecuteTool(CLIExecuteConfig{ + AllowedBinaries: []string{"echo"}, + }) + if got := tool.Name(); got != "cli_execute" { + t.Errorf("Name() = %q, want %q", got, "cli_execute") + } +} + +func TestCLIExecute_Category(t *testing.T) { + tool := NewCLIExecuteTool(CLIExecuteConfig{ + AllowedBinaries: []string{"echo"}, + }) + if got := tool.Category(); got != "builtin" { + t.Errorf("Category() = %q, want %q", got, "builtin") + } +} + +func TestCLIExecute_DynamicSchema(t *testing.T) { + tool := NewCLIExecuteTool(CLIExecuteConfig{ + AllowedBinaries: []string{"curl", "jq"}, + }) + schema := tool.InputSchema() + + var parsed map[string]any + if err := json.Unmarshal(schema, &parsed); err != nil { + t.Fatalf("InputSchema() returned invalid JSON: %v", err) + } + + props, ok := parsed["properties"].(map[string]any) + if !ok { + t.Fatal("InputSchema() missing 'properties'") + } + binaryProp, ok := props["binary"].(map[string]any) + if !ok { + t.Fatal("InputSchema() missing 'binary' property") + } + enumVals, ok := binaryProp["enum"].([]any) + if !ok { + t.Fatal("InputSchema() missing 'enum' on binary property") + } + if len(enumVals) != 2 { + t.Errorf("InputSchema() enum has %d items, want 2", len(enumVals)) + } + + found := map[string]bool{} + for _, v := range enumVals { + found[v.(string)] = true + } + if !found["curl"] || !found["jq"] { + t.Errorf("InputSchema() enum = %v, want [curl, jq]", enumVals) + } +} + +func TestCLIExecute_Allowed(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("echo behavior differs on Windows") + } + + tool := NewCLIExecuteTool(CLIExecuteConfig{ + AllowedBinaries: []string{"echo"}, + }) + + args, _ := json.Marshal(cliExecuteArgs{ + Binary: "echo", + Args: []string{"hello"}, + }) + + result, err := tool.Execute(context.Background(), args) + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + + var res cliExecuteResult + if err := json.Unmarshal([]byte(result), &res); err != nil { + t.Fatalf("failed to unmarshal result: %v", err) + } + + if got := strings.TrimSpace(res.Stdout); got != "hello" { + t.Errorf("stdout = %q, want %q", got, "hello") + } + if res.ExitCode != 0 { + t.Errorf("exit_code = %d, want 0", res.ExitCode) + } +} + +func TestCLIExecute_Disallowed(t *testing.T) { + tool := NewCLIExecuteTool(CLIExecuteConfig{ + AllowedBinaries: []string{"echo"}, + }) + + args, _ := json.Marshal(cliExecuteArgs{ + Binary: "rm", + }) + + _, err := tool.Execute(context.Background(), args) + if err == nil { + t.Fatal("Execute() expected error for disallowed binary, got nil") + } + if !strings.Contains(err.Error(), "not in the allowed list") { + t.Errorf("error = %q, want it to mention 'not in the allowed list'", err.Error()) + } +} + +func TestCLIExecute_ShellInjection(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("echo behavior differs on Windows") + } + + tool := NewCLIExecuteTool(CLIExecuteConfig{ + AllowedBinaries: []string{"echo"}, + }) + + tests := []struct { + name string + arg string + }{ + {"backtick", "hello `whoami`"}, + {"command_substitution", "hello $(whoami)"}, + {"newline", "hello\nworld"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + args, _ := json.Marshal(cliExecuteArgs{ + Binary: "echo", + Args: []string{tt.arg}, + }) + + _, err := tool.Execute(context.Background(), args) + if err == nil { + t.Errorf("Execute() expected error for injection arg %q, got nil", tt.arg) + } + }) + } +} + +func TestCLIExecute_Timeout(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("sleep not available on Windows") + } + + tool := NewCLIExecuteTool(CLIExecuteConfig{ + AllowedBinaries: []string{"sleep"}, + TimeoutSeconds: 1, + }) + + args, _ := json.Marshal(cliExecuteArgs{ + Binary: "sleep", + Args: []string{"10"}, + }) + + _, err := tool.Execute(context.Background(), args) + if err == nil { + t.Fatal("Execute() expected timeout error, got nil") + } + if !strings.Contains(err.Error(), "timed out") { + t.Errorf("error = %q, want it to mention 'timed out'", err.Error()) + } +} + +func TestCLIExecute_OutputLimit(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("dd not available on Windows") + } + + tool := NewCLIExecuteTool(CLIExecuteConfig{ + AllowedBinaries: []string{"dd"}, + MaxOutputBytes: 100, // very small limit + }) + + // dd will produce more than 100 bytes of output + args, _ := json.Marshal(cliExecuteArgs{ + Binary: "dd", + Args: []string{"if=/dev/zero", "bs=1024", "count=1"}, + }) + + result, err := tool.Execute(context.Background(), args) + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + + var res cliExecuteResult + if err := json.Unmarshal([]byte(result), &res); err != nil { + t.Fatalf("failed to unmarshal result: %v", err) + } + + if !res.Truncated { + t.Error("expected truncated = true") + } +} + +func TestCLIExecute_MissingBinary(t *testing.T) { + tool := NewCLIExecuteTool(CLIExecuteConfig{ + AllowedBinaries: []string{"nonexistent_binary_xyz_12345"}, + }) + + avail, missing := tool.Availability() + if len(avail) != 0 { + t.Errorf("Availability() available = %v, want empty", avail) + } + if len(missing) != 1 || missing[0] != "nonexistent_binary_xyz_12345" { + t.Errorf("Availability() missing = %v, want [nonexistent_binary_xyz_12345]", missing) + } + + args, _ := json.Marshal(cliExecuteArgs{ + Binary: "nonexistent_binary_xyz_12345", + }) + + _, err := tool.Execute(context.Background(), args) + if err == nil { + t.Fatal("Execute() expected error for missing binary, got nil") + } + if !strings.Contains(err.Error(), "not found on this system") { + t.Errorf("error = %q, want it to mention 'not found on this system'", err.Error()) + } +} + +func TestCLIExecute_EnvIsolation(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("env command differs on Windows") + } + + tool := NewCLIExecuteTool(CLIExecuteConfig{ + AllowedBinaries: []string{"env"}, + EnvPassthrough: []string{"FORGE_TEST_VAR"}, + }) + + // Set a test var and a var that should NOT pass through + t.Setenv("FORGE_TEST_VAR", "test_value") + t.Setenv("SECRET_VAR", "should_not_appear") + + args, _ := json.Marshal(cliExecuteArgs{ + Binary: "env", + }) + + result, err := tool.Execute(context.Background(), args) + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + + var res cliExecuteResult + if err := json.Unmarshal([]byte(result), &res); err != nil { + t.Fatalf("failed to unmarshal result: %v", err) + } + + // Check passthrough var is present + if !strings.Contains(res.Stdout, "FORGE_TEST_VAR=test_value") { + t.Error("expected FORGE_TEST_VAR=test_value in env output") + } + + // Check secret var is NOT present + if strings.Contains(res.Stdout, "SECRET_VAR") { + t.Error("SECRET_VAR should not appear in isolated env output") + } + + // Check only expected vars are present + lines := strings.Split(strings.TrimSpace(res.Stdout), "\n") + for _, line := range lines { + parts := strings.SplitN(line, "=", 2) + if len(parts) < 2 { + continue + } + key := parts[0] + allowed := map[string]bool{ + "PATH": true, "HOME": true, "LANG": true, "FORGE_TEST_VAR": true, + } + if !allowed[key] { + t.Errorf("unexpected env var in output: %s", key) + } + } +} + +func TestCLIExecute_Stdin(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("cat behavior differs on Windows") + } + + tool := NewCLIExecuteTool(CLIExecuteConfig{ + AllowedBinaries: []string{"cat"}, + }) + + args, _ := json.Marshal(cliExecuteArgs{ + Binary: "cat", + Stdin: "hello from stdin", + }) + + result, err := tool.Execute(context.Background(), args) + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + + var res cliExecuteResult + if err := json.Unmarshal([]byte(result), &res); err != nil { + t.Fatalf("failed to unmarshal result: %v", err) + } + + if res.Stdout != "hello from stdin" { + t.Errorf("stdout = %q, want %q", res.Stdout, "hello from stdin") + } +} + +func TestCLIExecute_ParseConfig(t *testing.T) { + raw := map[string]any{ + "allowed_binaries": []any{"curl", "jq", "yq"}, + "env_passthrough": []any{"GITHUB_TOKEN"}, + "timeout": 120, + "max_output_bytes": 1048576, + } + + cfg := ParseCLIExecuteConfig(raw) + + if len(cfg.AllowedBinaries) != 3 { + t.Errorf("AllowedBinaries = %v, want 3 items", cfg.AllowedBinaries) + } + if cfg.AllowedBinaries[0] != "curl" || cfg.AllowedBinaries[1] != "jq" || cfg.AllowedBinaries[2] != "yq" { + t.Errorf("AllowedBinaries = %v, want [curl jq yq]", cfg.AllowedBinaries) + } + + if len(cfg.EnvPassthrough) != 1 || cfg.EnvPassthrough[0] != "GITHUB_TOKEN" { + t.Errorf("EnvPassthrough = %v, want [GITHUB_TOKEN]", cfg.EnvPassthrough) + } + + if cfg.TimeoutSeconds != 120 { + t.Errorf("TimeoutSeconds = %d, want 120", cfg.TimeoutSeconds) + } + + if cfg.MaxOutputBytes != 1048576 { + t.Errorf("MaxOutputBytes = %d, want 1048576", cfg.MaxOutputBytes) + } + + // Test with float64 (JSON round-trip) + rawFloat := map[string]any{ + "allowed_binaries": []any{"echo"}, + "timeout": float64(60), + "max_output_bytes": float64(2097152), + } + + cfgFloat := ParseCLIExecuteConfig(rawFloat) + if cfgFloat.TimeoutSeconds != 60 { + t.Errorf("TimeoutSeconds (float64) = %d, want 60", cfgFloat.TimeoutSeconds) + } + if cfgFloat.MaxOutputBytes != 2097152 { + t.Errorf("MaxOutputBytes (float64) = %d, want 2097152", cfgFloat.MaxOutputBytes) + } +} diff --git a/forge-cli/tools/devtools/devtools_test.go b/forge-cli/tools/devtools/devtools_test.go new file mode 100644 index 0000000..8b9b691 --- /dev/null +++ b/forge-cli/tools/devtools/devtools_test.go @@ -0,0 +1,107 @@ +package devtools + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/initializ/forge/forge-core/tools" +) + +func TestLocalShellTool(t *testing.T) { + dir := t.TempDir() + tool := NewLocalShellTool(dir) + + if tool.Name() != "local_shell" { + t.Errorf("name: got %q", tool.Name()) + } + if tool.Category() != tools.CategoryDev { + t.Errorf("category: got %q", tool.Category()) + } + + args, _ := json.Marshal(map[string]string{ + "command": "echo hello", + }) + + result, err := tool.Execute(context.Background(), args) + if err != nil { + t.Fatalf("Execute error: %v", err) + } + if !strings.Contains(result, "hello") { + t.Errorf("result should contain 'hello': %q", result) + } + + // Verify exit code + var output map[string]any + json.Unmarshal([]byte(result), &output) //nolint:errcheck + if output["exit_code"] != float64(0) { + t.Errorf("exit_code: got %v", output["exit_code"]) + } +} + +func TestLocalFileBrowserTool_ReadFile(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "test.txt"), []byte("file content"), 0644) //nolint:errcheck + + tool := NewLocalFileBrowserTool(dir) + + if tool.Name() != "local_file_browser" { + t.Errorf("name: got %q", tool.Name()) + } + if tool.Category() != tools.CategoryDev { + t.Errorf("category: got %q", tool.Category()) + } + + args, _ := json.Marshal(map[string]string{ + "path": "test.txt", + "operation": "read", + }) + + result, err := tool.Execute(context.Background(), args) + if err != nil { + t.Fatalf("Execute error: %v", err) + } + if result != "file content" { + t.Errorf("result: got %q", result) + } +} + +func TestLocalFileBrowserTool_ListDir(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a"), 0644) //nolint:errcheck + os.Mkdir(filepath.Join(dir, "subdir"), 0755) //nolint:errcheck + + tool := NewLocalFileBrowserTool(dir) + args, _ := json.Marshal(map[string]string{ + "path": ".", + "operation": "list", + }) + + result, err := tool.Execute(context.Background(), args) + if err != nil { + t.Fatalf("Execute error: %v", err) + } + if !strings.Contains(result, "a.txt") { + t.Errorf("result should list files: %q", result) + } + if !strings.Contains(result, "subdir") { + t.Errorf("result should list dirs: %q", result) + } +} + +func TestLocalFileBrowserTool_PathTraversal(t *testing.T) { + dir := t.TempDir() + tool := NewLocalFileBrowserTool(dir) + + args, _ := json.Marshal(map[string]string{ + "path": "../../etc/passwd", + }) + + _, err := tool.Execute(context.Background(), args) + if err == nil { + t.Fatal("expected error for path traversal") + } +} diff --git a/forge-cli/tools/devtools/local_file_browser.go b/forge-cli/tools/devtools/local_file_browser.go new file mode 100644 index 0000000..fea0623 --- /dev/null +++ b/forge-cli/tools/devtools/local_file_browser.go @@ -0,0 +1,109 @@ +package devtools + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/initializ/forge/forge-core/tools" +) + +// LocalFileBrowserTool reads and lists files in a project directory. +type LocalFileBrowserTool struct { + workDir string +} + +type localFileBrowserInput struct { + Path string `json:"path"` + Operation string `json:"operation,omitempty"` +} + +// NewLocalFileBrowserTool creates a file browser tool for the given directory. +func NewLocalFileBrowserTool(workDir string) *LocalFileBrowserTool { + return &LocalFileBrowserTool{workDir: workDir} +} + +func (t *LocalFileBrowserTool) Name() string { return "local_file_browser" } +func (t *LocalFileBrowserTool) Description() string { + return "Read and list files in the project directory" +} +func (t *LocalFileBrowserTool) Category() tools.Category { return tools.CategoryDev } + +func (t *LocalFileBrowserTool) InputSchema() json.RawMessage { + return json.RawMessage(`{ + "type": "object", + "properties": { + "path": {"type": "string", "description": "Relative path within the project directory"}, + "operation": {"type": "string", "enum": ["read", "list"], "description": "Operation: read file contents or list directory (default: read)"} + }, + "required": ["path"] + }`) +} + +func (t *LocalFileBrowserTool) Execute(ctx context.Context, args json.RawMessage) (string, error) { + var input localFileBrowserInput + if err := json.Unmarshal(args, &input); err != nil { + return "", fmt.Errorf("parsing input: %w", err) + } + + // Resolve and validate path is within workDir + absWorkDir, _ := filepath.Abs(t.workDir) + targetPath := filepath.Join(absWorkDir, filepath.Clean(input.Path)) + if !strings.HasPrefix(targetPath, absWorkDir) { + return "", fmt.Errorf("path escapes project directory") + } + + op := input.Operation + if op == "" { + op = "read" + } + + switch op { + case "list": + return t.listDir(targetPath) + case "read": + return t.readFile(targetPath) + default: + return "", fmt.Errorf("unknown operation: %q", op) + } +} + +func (t *LocalFileBrowserTool) listDir(path string) (string, error) { + entries, err := os.ReadDir(path) + if err != nil { + return "", fmt.Errorf("listing directory: %w", err) + } + + var items []map[string]any + for _, entry := range entries { + item := map[string]any{ + "name": entry.Name(), + "is_dir": entry.IsDir(), + } + if info, err := entry.Info(); err == nil { + item["size"] = info.Size() + } + items = append(items, item) + } + + data, _ := json.MarshalIndent(items, "", " ") + return string(data), nil +} + +func (t *LocalFileBrowserTool) readFile(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("reading file: %w", err) + } + + // Limit to 100KB + if len(data) > 100*1024 { + data = data[:100*1024] + return string(data) + "\n... (truncated at 100KB)", nil + } + + return string(data), nil +} diff --git a/forge-cli/tools/devtools/local_shell.go b/forge-cli/tools/devtools/local_shell.go new file mode 100644 index 0000000..7e2d988 --- /dev/null +++ b/forge-cli/tools/devtools/local_shell.go @@ -0,0 +1,88 @@ +// Package devtools provides developer tools that are only available with --dev flag. +package devtools + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/initializ/forge/forge-core/tools" +) + +// LocalShellTool executes shell commands sandboxed to a work directory. +type LocalShellTool struct { + workDir string +} + +type localShellInput struct { + Command string `json:"command"` + Timeout int `json:"timeout,omitempty"` +} + +// NewLocalShellTool creates a shell tool sandboxed to the given directory. +func NewLocalShellTool(workDir string) *LocalShellTool { + return &LocalShellTool{workDir: workDir} +} + +func (t *LocalShellTool) Name() string { return "local_shell" } +func (t *LocalShellTool) Description() string { + return "Execute shell commands in the project directory" +} +func (t *LocalShellTool) Category() tools.Category { return tools.CategoryDev } + +func (t *LocalShellTool) InputSchema() json.RawMessage { + return json.RawMessage(`{ + "type": "object", + "properties": { + "command": {"type": "string", "description": "Shell command to execute"}, + "timeout": {"type": "integer", "description": "Timeout in seconds (default 30)"} + }, + "required": ["command"] + }`) +} + +func (t *LocalShellTool) Execute(ctx context.Context, args json.RawMessage) (string, error) { + var input localShellInput + if err := json.Unmarshal(args, &input); err != nil { + return "", fmt.Errorf("parsing input: %w", err) + } + + timeout := time.Duration(input.Timeout) * time.Second + if timeout == 0 { + timeout = 30 * time.Second + } + + cmdCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + cmd := exec.CommandContext(cmdCtx, "sh", "-c", input.Command) + cmd.Dir = t.workDir + + // Prevent path traversal + absWorkDir, _ := filepath.Abs(t.workDir) + cmd.Env = append(cmd.Environ(), "HOME="+absWorkDir) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + + result := map[string]any{ + "stdout": strings.TrimRight(stdout.String(), "\n"), + "stderr": strings.TrimRight(stderr.String(), "\n"), + "exit_code": cmd.ProcessState.ExitCode(), + } + + if err != nil { + result["error"] = err.Error() + } + + data, _ := json.Marshal(result) + return string(data), nil +} diff --git a/forge-cli/tools/discovery.go b/forge-cli/tools/discovery.go new file mode 100644 index 0000000..992fa62 --- /dev/null +++ b/forge-cli/tools/discovery.go @@ -0,0 +1,12 @@ +package tools + +import ( + "os" + + coretools "github.com/initializ/forge/forge-core/tools" +) + +// DiscoverTools scans the given directory for tool scripts/modules. +func DiscoverTools(dir string) []coretools.DiscoveredTool { + return coretools.DiscoverToolsFS(os.DirFS(dir)) +} diff --git a/forge-cli/tools/exec.go b/forge-cli/tools/exec.go new file mode 100644 index 0000000..4485ed6 --- /dev/null +++ b/forge-cli/tools/exec.go @@ -0,0 +1,33 @@ +package tools + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "time" +) + +// OSCommandExecutor implements tools.CommandExecutor using os/exec. +type OSCommandExecutor struct{} + +func (e *OSCommandExecutor) Run(ctx context.Context, command string, args []string, stdin []byte) (string, error) { + cmdCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + cmd := exec.CommandContext(cmdCtx, command, args...) + cmd.Stdin = bytes.NewReader(stdin) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + if stderr.Len() > 0 { + return "", fmt.Errorf("command error: %s", stderr.String()) + } + return "", fmt.Errorf("command execution failed: %w", err) + } + + return stdout.String(), nil +} diff --git a/forge-core/a2a/jsonrpc.go b/forge-core/a2a/jsonrpc.go new file mode 100644 index 0000000..967335f --- /dev/null +++ b/forge-core/a2a/jsonrpc.go @@ -0,0 +1,72 @@ +package a2a + +import "encoding/json" + +// JSON-RPC 2.0 error codes. +const ( + ErrCodeParseError = -32700 + ErrCodeInvalidRequest = -32600 + ErrCodeMethodNotFound = -32601 + ErrCodeInvalidParams = -32602 + ErrCodeInternal = -32603 +) + +// JSONRPCRequest is an incoming JSON-RPC 2.0 request. +type JSONRPCRequest struct { + JSONRPC string `json:"jsonrpc"` + ID any `json:"id,omitempty"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` +} + +// JSONRPCResponse is an outgoing JSON-RPC 2.0 response. +type JSONRPCResponse struct { + JSONRPC string `json:"jsonrpc"` + ID any `json:"id,omitempty"` + Result any `json:"result,omitempty"` + Error *JSONRPCError `json:"error,omitempty"` +} + +// JSONRPCError carries error information in a JSON-RPC response. +type JSONRPCError struct { + Code int `json:"code"` + Message string `json:"message"` + Data any `json:"data,omitempty"` +} + +// SendTaskParams are the parameters for tasks/send and tasks/sendSubscribe. +type SendTaskParams struct { + ID string `json:"id"` + Message Message `json:"message"` +} + +// GetTaskParams are the parameters for tasks/get. +type GetTaskParams struct { + ID string `json:"id"` +} + +// CancelTaskParams are the parameters for tasks/cancel. +type CancelTaskParams struct { + ID string `json:"id"` +} + +// NewResponse creates a successful JSON-RPC 2.0 response. +func NewResponse(id any, result any) *JSONRPCResponse { + return &JSONRPCResponse{ + JSONRPC: "2.0", + ID: id, + Result: result, + } +} + +// NewErrorResponse creates an error JSON-RPC 2.0 response. +func NewErrorResponse(id any, code int, msg string) *JSONRPCResponse { + return &JSONRPCResponse{ + JSONRPC: "2.0", + ID: id, + Error: &JSONRPCError{ + Code: code, + Message: msg, + }, + } +} diff --git a/forge-core/a2a/taskstore.go b/forge-core/a2a/taskstore.go new file mode 100644 index 0000000..16dc613 --- /dev/null +++ b/forge-core/a2a/taskstore.go @@ -0,0 +1,69 @@ +package a2a + +import ( + "encoding/json" + "sync" +) + +// TaskStore is a thread-safe in-memory store for A2A tasks. +type TaskStore struct { + mu sync.RWMutex + tasks map[string]*Task +} + +// NewTaskStore creates an empty TaskStore. +func NewTaskStore() *TaskStore { + return &TaskStore{tasks: make(map[string]*Task)} +} + +// Get returns a deep copy of the task with the given ID, or nil if not found. +func (s *TaskStore) Get(id string) *Task { + s.mu.RLock() + defer s.mu.RUnlock() + t, ok := s.tasks[id] + if !ok { + return nil + } + return deepCopyTask(t) +} + +// Put stores a task. It overwrites any existing task with the same ID. +func (s *TaskStore) Put(t *Task) { + s.mu.Lock() + defer s.mu.Unlock() + s.tasks[t.ID] = deepCopyTask(t) +} + +// UpdateStatus updates the status of an existing task. Returns false if the +// task does not exist. +func (s *TaskStore) UpdateStatus(id string, status TaskStatus) bool { + s.mu.Lock() + defer s.mu.Unlock() + t, ok := s.tasks[id] + if !ok { + return false + } + t.Status = status + return true +} + +// SetArtifacts replaces the artifacts for an existing task. Returns false if +// the task does not exist. +func (s *TaskStore) SetArtifacts(id string, artifacts []Artifact) bool { + s.mu.Lock() + defer s.mu.Unlock() + t, ok := s.tasks[id] + if !ok { + return false + } + t.Artifacts = artifacts + return true +} + +// deepCopyTask creates a deep copy by JSON round-tripping. +func deepCopyTask(t *Task) *Task { + data, _ := json.Marshal(t) + var copy Task + json.Unmarshal(data, ©) //nolint:errcheck + return © +} diff --git a/forge-core/a2a/types.go b/forge-core/a2a/types.go new file mode 100644 index 0000000..6d6a3fd --- /dev/null +++ b/forge-core/a2a/types.go @@ -0,0 +1,117 @@ +// Package a2a provides shared types for the Agent-to-Agent (A2A) protocol. +package a2a + +// TaskState represents the possible states of an A2A task. +type TaskState string + +const ( + TaskStateSubmitted TaskState = "submitted" + TaskStateWorking TaskState = "working" + TaskStateCompleted TaskState = "completed" + TaskStateFailed TaskState = "failed" + TaskStateCanceled TaskState = "canceled" + TaskStateInputRequired TaskState = "input-required" + TaskStateAuthRequired TaskState = "auth-required" + TaskStateRejected TaskState = "rejected" +) + +// TaskStatus holds the current state of a task along with an optional message. +type TaskStatus struct { + State TaskState `json:"state"` + Message *Message `json:"message,omitempty"` +} + +// Task represents an A2A task exchanged between agents. +type Task struct { + ID string `json:"id"` + Status TaskStatus `json:"status"` + History []Message `json:"history,omitempty"` + Artifacts []Artifact `json:"artifacts,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +// MessageRole indicates who produced a message. +type MessageRole string + +const ( + MessageRoleUser MessageRole = "user" + MessageRoleAgent MessageRole = "agent" +) + +// Message is a single conversational turn in the A2A protocol. +type Message struct { + Role MessageRole `json:"role"` + Parts []Part `json:"parts"` +} + +// PartKind discriminates the content type of a Part. +type PartKind string + +const ( + PartKindText PartKind = "text" + PartKindData PartKind = "data" + PartKindFile PartKind = "file" +) + +// Part is a flat union struct representing a piece of message content. +// Exactly one of Text, Data, or File should be set, indicated by Kind. +type Part struct { + Kind PartKind `json:"kind"` + Text string `json:"text,omitempty"` + Data any `json:"data,omitempty"` + File *FileContent `json:"file,omitempty"` +} + +// FileContent holds the contents or reference for a file part. +type FileContent struct { + Name string `json:"name,omitempty"` + MimeType string `json:"mimeType,omitempty"` + URI string `json:"uri,omitempty"` + Bytes []byte `json:"bytes,omitempty"` +} + +// Artifact is a named output produced by an agent task. +type Artifact struct { + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Parts []Part `json:"parts"` +} + +// AgentCard describes an agent's capabilities for discovery. +type AgentCard struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + URL string `json:"url"` + Skills []Skill `json:"skills,omitempty"` + Capabilities *AgentCapabilities `json:"capabilities,omitempty"` +} + +// AgentCapabilities declares optional A2A features an agent supports. +type AgentCapabilities struct { + Streaming bool `json:"streaming,omitempty"` + PushNotifications bool `json:"pushNotifications,omitempty"` + StateTransitionHistory bool `json:"stateTransitionHistory,omitempty"` +} + +// Skill describes a discrete capability an agent exposes. +type Skill struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Tags []string `json:"tags,omitempty"` +} + +// NewTextPart creates a Part containing text content. +func NewTextPart(text string) Part { + return Part{Kind: PartKindText, Text: text} +} + +// NewDataPart creates a Part containing structured data. +func NewDataPart(data any) Part { + return Part{Kind: PartKindData, Data: data} +} + +// NewFilePart creates a Part referencing a file. +func NewFilePart(file FileContent) Part { + return Part{Kind: PartKindFile, File: &file} +} diff --git a/forge-core/agentspec/agentspec_test.go b/forge-core/agentspec/agentspec_test.go new file mode 100644 index 0000000..94f189a --- /dev/null +++ b/forge-core/agentspec/agentspec_test.go @@ -0,0 +1,328 @@ +package agentspec + +import ( + "encoding/json" + "testing" +) + +func TestAgentSpec_JSONRoundTrip(t *testing.T) { + spec := AgentSpec{ + ForgeVersion: "1.0", + AgentID: "test-agent", + Version: "1.0.0", + Name: "Test Agent", + Description: "A test agent", + Runtime: &RuntimeConfig{ + Image: "python:3.11-slim", + Entrypoint: []string{"python", "main.py"}, + Port: 8080, + Env: map[string]string{"LOG_LEVEL": "info"}, + }, + Tools: []ToolSpec{ + {Name: "web-search", Description: "Search the web"}, + }, + PolicyScaffold: &PolicyScaffold{ + Guardrails: []Guardrail{ + {Type: "content_filter", Config: map[string]any{"blocked": true}}, + }, + }, + Identity: &Identity{ + Issuer: "https://auth.example.com", + Audience: "forge-agents", + Scopes: []string{"read", "write"}, + }, + A2A: &A2AConfig{ + Endpoint: "/a2a", + Skills: []A2ASkill{ + {ID: "search", Name: "Web Search", Description: "Search the web", Tags: []string{"search"}}, + }, + Capabilities: &A2ACapabilities{Streaming: true}, + }, + Model: &ModelConfig{ + Provider: "openai", + Name: "gpt-4", + Version: "latest", + Parameters: map[string]any{"temperature": 0.7}, + }, + ToolInterfaceVersion: "1.0", + SkillsSpecVersion: "agentskills-v1", + ForgeSkillsExtVersion: "1.0", + EgressProfile: "strict", + EgressMode: "deny-all", + } + + data, err := json.Marshal(spec) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var got AgentSpec + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if got.AgentID != spec.AgentID { + t.Errorf("AgentID = %q, want %q", got.AgentID, spec.AgentID) + } + if got.Runtime.Port != 8080 { + t.Errorf("Runtime.Port = %d, want 8080", got.Runtime.Port) + } + if len(got.Tools) != 1 || got.Tools[0].Name != "web-search" { + t.Errorf("Tools mismatch: %+v", got.Tools) + } + if got.A2A.Skills[0].ID != "search" { + t.Errorf("A2A.Skills[0].ID = %q, want %q", got.A2A.Skills[0].ID, "search") + } + if got.Model.Provider != "openai" { + t.Errorf("Model.Provider = %q, want %q", got.Model.Provider, "openai") + } + if got.ToolInterfaceVersion != spec.ToolInterfaceVersion { + t.Errorf("ToolInterfaceVersion = %q, want %q", got.ToolInterfaceVersion, spec.ToolInterfaceVersion) + } + if got.EgressProfile != spec.EgressProfile { + t.Errorf("EgressProfile = %q, want %q", got.EgressProfile, spec.EgressProfile) + } +} + +func TestAgentSpec_BackwardsCompat(t *testing.T) { + // Old JSON without new fields should unmarshal without error + oldJSON := `{"forge_version":"1.0","agent_id":"old-agent","version":"1.0.0","name":"Old Agent"}` + var spec AgentSpec + if err := json.Unmarshal([]byte(oldJSON), &spec); err != nil { + t.Fatalf("unmarshal old JSON: %v", err) + } + if spec.AgentID != "old-agent" { + t.Errorf("AgentID = %q, want %q", spec.AgentID, "old-agent") + } + if spec.ToolInterfaceVersion != "" { + t.Errorf("ToolInterfaceVersion should be empty, got %q", spec.ToolInterfaceVersion) + } + if spec.EgressProfile != "" { + t.Errorf("EgressProfile should be empty, got %q", spec.EgressProfile) + } +} + +func TestToolSpec_Category(t *testing.T) { + tool := ToolSpec{ + Name: "web_search", + Category: "builtin", + } + data, err := json.Marshal(tool) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var got ToolSpec + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.Category != "builtin" { + t.Errorf("Category = %q, want %q", got.Category, "builtin") + } + + // Category omitted when empty + tool2 := ToolSpec{Name: "test"} + data2, _ := json.Marshal(tool2) + var raw map[string]any + _ = json.Unmarshal(data2, &raw) + if _, ok := raw["category"]; ok { + t.Error("expected category to be omitted when empty") + } +} + +func TestAgentSpec_OmitEmpty(t *testing.T) { + spec := AgentSpec{ + ForgeVersion: "1.0", + AgentID: "minimal", + Version: "0.1.0", + Name: "Minimal", + } + + data, err := json.Marshal(spec) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("unmarshal raw: %v", err) + } + + for _, key := range []string{"description", "runtime", "tools", "policy_scaffold", "identity", "a2a", "model", "tool_interface_version", "skills_spec_version", "forge_skills_ext_version", "egress_profile", "egress_mode"} { + if _, ok := raw[key]; ok { + t.Errorf("expected key %q to be omitted from zero-value AgentSpec", key) + } + } +} + +func TestToolSpec_InputSchemaRawMessage(t *testing.T) { + schema := json.RawMessage(`{"type":"object","properties":{"q":{"type":"string"}}}`) + tool := ToolSpec{ + Name: "search", + Description: "Search tool", + InputSchema: schema, + Permissions: []string{"network"}, + ForgeMeta: &ForgeToolMeta{ + AllowedTables: []string{"users"}, + AllowedEndpoints: []string{"/api/v1"}, + }, + } + + data, err := json.Marshal(tool) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var got ToolSpec + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if string(got.InputSchema) != string(schema) { + t.Errorf("InputSchema = %s, want %s", got.InputSchema, schema) + } + if got.ForgeMeta.AllowedTables[0] != "users" { + t.Errorf("ForgeMeta.AllowedTables[0] = %q, want %q", got.ForgeMeta.AllowedTables[0], "users") + } +} + +func TestRuntimeConfig_JSONRoundTrip(t *testing.T) { + rc := RuntimeConfig{ + Image: "python:3.11-slim", + Entrypoint: []string{"python", "main.py"}, + Port: 8080, + Env: map[string]string{"KEY": "value"}, + DepsFile: "requirements.txt", + DepsInstallCmd: "pip install -r requirements.txt", + HealthCheck: "/healthz", + User: "app", + } + + data, err := json.Marshal(rc) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var got RuntimeConfig + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if got.Image != rc.Image { + t.Errorf("Image = %q, want %q", got.Image, rc.Image) + } + if got.DepsFile != rc.DepsFile { + t.Errorf("DepsFile = %q, want %q", got.DepsFile, rc.DepsFile) + } + if got.User != rc.User { + t.Errorf("User = %q, want %q", got.User, rc.User) + } +} + +func TestPolicyScaffold_JSONRoundTrip(t *testing.T) { + ps := PolicyScaffold{ + Guardrails: []Guardrail{ + { + Type: "content_filter", + Config: map[string]any{"blocked_categories": []any{"violence", "profanity"}}, + }, + { + Type: "no_pii", + Config: map[string]any{"fields": []any{"ssn", "email"}}, + }, + }, + } + + data, err := json.Marshal(ps) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var got PolicyScaffold + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if len(got.Guardrails) != 2 { + t.Fatalf("Guardrails count = %d, want 2", len(got.Guardrails)) + } + if got.Guardrails[0].Type != "content_filter" { + t.Errorf("Guardrails[0].Type = %q, want %q", got.Guardrails[0].Type, "content_filter") + } + if got.Guardrails[1].Type != "no_pii" { + t.Errorf("Guardrails[1].Type = %q, want %q", got.Guardrails[1].Type, "no_pii") + } +} + +func TestModelConfig_Parameters(t *testing.T) { + mc := ModelConfig{ + Provider: "openai", + Name: "gpt-4", + Version: "latest", + Parameters: map[string]any{ + "temperature": 0.7, + "max_tokens": 4096.0, + "top_p": 0.9, + }, + } + + data, err := json.Marshal(mc) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var got ModelConfig + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if got.Provider != mc.Provider { + t.Errorf("Provider = %q, want %q", got.Provider, mc.Provider) + } + if len(got.Parameters) != 3 { + t.Errorf("Parameters count = %d, want 3", len(got.Parameters)) + } + if temp, ok := got.Parameters["temperature"].(float64); !ok || temp != 0.7 { + t.Errorf("Parameters[temperature] = %v, want 0.7", got.Parameters["temperature"]) + } +} + +func TestA2AConfig_Skills(t *testing.T) { + cfg := A2AConfig{ + Endpoint: "/a2a", + Skills: []A2ASkill{ + {ID: "search", Name: "Web Search", Description: "Search the web", Tags: []string{"search", "web"}}, + {ID: "summarize", Name: "Summarize", Description: "Summarize text", Tags: []string{"nlp"}}, + }, + Capabilities: &A2ACapabilities{ + Streaming: true, + PushNotifications: false, + StateTransitionHistory: true, + }, + } + + data, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var got A2AConfig + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if len(got.Skills) != 2 { + t.Fatalf("Skills count = %d, want 2", len(got.Skills)) + } + if got.Skills[0].ID != "search" { + t.Errorf("Skills[0].ID = %q, want %q", got.Skills[0].ID, "search") + } + if got.Skills[1].Tags[0] != "nlp" { + t.Errorf("Skills[1].Tags[0] = %q, want %q", got.Skills[1].Tags[0], "nlp") + } + if !got.Capabilities.Streaming { + t.Error("Capabilities.Streaming = false, want true") + } + if got.Capabilities.PushNotifications { + t.Error("Capabilities.PushNotifications = true, want false") + } +} diff --git a/forge-core/agentspec/policy_scaffold.go b/forge-core/agentspec/policy_scaffold.go new file mode 100644 index 0000000..958a504 --- /dev/null +++ b/forge-core/agentspec/policy_scaffold.go @@ -0,0 +1,12 @@ +package agentspec + +// PolicyScaffold defines the policy and guardrail configuration for an agent. +type PolicyScaffold struct { + Guardrails []Guardrail `json:"guardrails,omitempty" bson:"guardrails,omitempty" yaml:"guardrails,omitempty"` +} + +// Guardrail defines a single guardrail rule applied to an agent. +type Guardrail struct { + Type string `json:"type" bson:"type" yaml:"type"` + Config map[string]any `json:"config,omitempty" bson:"config,omitempty" yaml:"config,omitempty"` +} diff --git a/forge-core/agentspec/spec.go b/forge-core/agentspec/spec.go new file mode 100644 index 0000000..452bd07 --- /dev/null +++ b/forge-core/agentspec/spec.go @@ -0,0 +1,81 @@ +package agentspec + +// AgentSpec is the canonical in-memory representation of an agent specification. +type AgentSpec struct { + ForgeVersion string `json:"forge_version" bson:"forge_version" yaml:"forge_version"` + AgentID string `json:"agent_id" bson:"agent_id" yaml:"agent_id"` + Version string `json:"version" bson:"version" yaml:"version"` + Name string `json:"name" bson:"name" yaml:"name"` + Description string `json:"description,omitempty" bson:"description,omitempty" yaml:"description,omitempty"` + Runtime *RuntimeConfig `json:"runtime,omitempty" bson:"runtime,omitempty" yaml:"runtime,omitempty"` + Tools []ToolSpec `json:"tools,omitempty" bson:"tools,omitempty" yaml:"tools,omitempty"` + PolicyScaffold *PolicyScaffold `json:"policy_scaffold,omitempty" bson:"policy_scaffold,omitempty" yaml:"policy_scaffold,omitempty"` + Identity *Identity `json:"identity,omitempty" bson:"identity,omitempty" yaml:"identity,omitempty"` + A2A *A2AConfig `json:"a2a,omitempty" bson:"a2a,omitempty" yaml:"a2a,omitempty"` + Model *ModelConfig `json:"model,omitempty" bson:"model,omitempty" yaml:"model,omitempty"` + + Requirements *AgentRequirements `json:"requirements,omitempty" bson:"requirements,omitempty" yaml:"requirements,omitempty"` + + // Container packaging extensions + ToolInterfaceVersion string `json:"tool_interface_version,omitempty" bson:"tool_interface_version,omitempty" yaml:"tool_interface_version,omitempty"` + SkillsSpecVersion string `json:"skills_spec_version,omitempty" bson:"skills_spec_version,omitempty" yaml:"skills_spec_version,omitempty"` + ForgeSkillsExtVersion string `json:"forge_skills_ext_version,omitempty" bson:"forge_skills_ext_version,omitempty" yaml:"forge_skills_ext_version,omitempty"` + EgressProfile string `json:"egress_profile,omitempty" bson:"egress_profile,omitempty" yaml:"egress_profile,omitempty"` + EgressMode string `json:"egress_mode,omitempty" bson:"egress_mode,omitempty" yaml:"egress_mode,omitempty"` +} + +// RuntimeConfig holds container runtime settings. +type RuntimeConfig struct { + Image string `json:"image,omitempty" bson:"image,omitempty" yaml:"image,omitempty"` + Entrypoint []string `json:"entrypoint,omitempty" bson:"entrypoint,omitempty" yaml:"entrypoint,omitempty"` + Port int `json:"port,omitempty" bson:"port,omitempty" yaml:"port,omitempty"` + Env map[string]string `json:"env,omitempty" bson:"env,omitempty" yaml:"env,omitempty"` + DepsFile string `json:"deps_file,omitempty" bson:"deps_file,omitempty" yaml:"deps_file,omitempty"` + DepsInstallCmd string `json:"deps_install_cmd,omitempty" bson:"deps_install_cmd,omitempty" yaml:"deps_install_cmd,omitempty"` + HealthCheck string `json:"health_check,omitempty" bson:"health_check,omitempty" yaml:"health_check,omitempty"` + User string `json:"user,omitempty" bson:"user,omitempty" yaml:"user,omitempty"` +} + +// Identity holds agent identity and authentication metadata. +type Identity struct { + Issuer string `json:"issuer,omitempty" bson:"issuer,omitempty" yaml:"issuer,omitempty"` + Audience string `json:"audience,omitempty" bson:"audience,omitempty" yaml:"audience,omitempty"` + Scopes []string `json:"scopes,omitempty" bson:"scopes,omitempty" yaml:"scopes,omitempty"` +} + +// A2AConfig holds Agent-to-Agent protocol settings. +type A2AConfig struct { + Endpoint string `json:"endpoint,omitempty" bson:"endpoint,omitempty" yaml:"endpoint,omitempty"` + Skills []A2ASkill `json:"skills,omitempty" bson:"skills,omitempty" yaml:"skills,omitempty"` + Capabilities *A2ACapabilities `json:"capabilities,omitempty" bson:"capabilities,omitempty" yaml:"capabilities,omitempty"` +} + +// A2ASkill describes a single skill exposed over the A2A protocol. +type A2ASkill struct { + ID string `json:"id" bson:"id" yaml:"id"` + Name string `json:"name" bson:"name" yaml:"name"` + Description string `json:"description,omitempty" bson:"description,omitempty" yaml:"description,omitempty"` + Tags []string `json:"tags,omitempty" bson:"tags,omitempty" yaml:"tags,omitempty"` +} + +// A2ACapabilities declares optional A2A features supported by the agent. +type A2ACapabilities struct { + Streaming bool `json:"streaming,omitempty" bson:"streaming,omitempty" yaml:"streaming,omitempty"` + PushNotifications bool `json:"push_notifications,omitempty" bson:"push_notifications,omitempty" yaml:"push_notifications,omitempty"` + StateTransitionHistory bool `json:"state_transition_history,omitempty" bson:"state_transition_history,omitempty" yaml:"state_transition_history,omitempty"` +} + +// AgentRequirements declares runtime requirements for the agent. +type AgentRequirements struct { + Bins []string `json:"bins,omitempty" bson:"bins,omitempty" yaml:"bins,omitempty"` + EnvRequired []string `json:"env_required,omitempty" bson:"env_required,omitempty" yaml:"env_required,omitempty"` + EnvOptional []string `json:"env_optional,omitempty" bson:"env_optional,omitempty" yaml:"env_optional,omitempty"` +} + +// ModelConfig holds LLM/model configuration for the agent. +type ModelConfig struct { + Provider string `json:"provider,omitempty" bson:"provider,omitempty" yaml:"provider,omitempty"` + Name string `json:"name,omitempty" bson:"name,omitempty" yaml:"name,omitempty"` + Version string `json:"version,omitempty" bson:"version,omitempty" yaml:"version,omitempty"` + Parameters map[string]any `json:"parameters,omitempty" bson:"parameters,omitempty" yaml:"parameters,omitempty"` +} diff --git a/forge-core/agentspec/tool_schema.go b/forge-core/agentspec/tool_schema.go new file mode 100644 index 0000000..30b3878 --- /dev/null +++ b/forge-core/agentspec/tool_schema.go @@ -0,0 +1,23 @@ +package agentspec + +import "encoding/json" + +// ToolSpec defines a tool available to an agent. +type ToolSpec struct { + Name string `json:"name" bson:"name" yaml:"name"` + Description string `json:"description,omitempty" bson:"description,omitempty" yaml:"description,omitempty"` + InputSchema json.RawMessage `json:"input_schema,omitempty" bson:"input_schema,omitempty" yaml:"input_schema,omitempty"` + Permissions []string `json:"permissions,omitempty" bson:"permissions,omitempty" yaml:"permissions,omitempty"` + ForgeMeta *ForgeToolMeta `json:"forge_meta,omitempty" bson:"forge_meta,omitempty" yaml:"forge_meta,omitempty"` + Category string `json:"category,omitempty" bson:"category,omitempty" yaml:"category,omitempty"` + SkillOrigin string `json:"skill_origin,omitempty" bson:"skill_origin,omitempty" yaml:"skill_origin,omitempty"` +} + +// ForgeToolMeta carries Forge-specific metadata for a tool. +type ForgeToolMeta struct { + AllowedTables []string `json:"allowed_tables,omitempty" bson:"allowed_tables,omitempty" yaml:"allowed_tables,omitempty"` + AllowedEndpoints []string `json:"allowed_endpoints,omitempty" bson:"allowed_endpoints,omitempty" yaml:"allowed_endpoints,omitempty"` + NetworkScopes []string `json:"network_scopes,omitempty" bson:"network_scopes,omitempty" yaml:"network_scopes,omitempty"` + AllowedBinaries []string `json:"allowed_binaries,omitempty" bson:"allowed_binaries,omitempty" yaml:"allowed_binaries,omitempty"` + EnvPassthrough []string `json:"env_passthrough,omitempty" bson:"env_passthrough,omitempty" yaml:"env_passthrough,omitempty"` +} diff --git a/forge-core/channels/env.go b/forge-core/channels/env.go new file mode 100644 index 0000000..ea24c64 --- /dev/null +++ b/forge-core/channels/env.go @@ -0,0 +1,22 @@ +package channels + +import ( + "os" + "strings" +) + +// ResolveEnvVars inspects cfg.Settings for keys ending in "_env" and resolves +// them from the environment. For example, a setting "bot_token_env": "SLACK_BOT_TOKEN" +// produces {"bot_token": os.Getenv("SLACK_BOT_TOKEN")}. +// Non-env settings are passed through unchanged. +func ResolveEnvVars(cfg *ChannelConfig) map[string]string { + resolved := make(map[string]string, len(cfg.Settings)) + for k, v := range cfg.Settings { + if base, ok := strings.CutSuffix(k, "_env"); ok { + resolved[base] = os.Getenv(v) + } else { + resolved[k] = v + } + } + return resolved +} diff --git a/forge-core/channels/plugin.go b/forge-core/channels/plugin.go new file mode 100644 index 0000000..463ea6b --- /dev/null +++ b/forge-core/channels/plugin.go @@ -0,0 +1,59 @@ +// Package channels defines the channel adapter architecture for exposing +// self-hosted agents via messaging platforms like Slack and Telegram. +package channels + +import ( + "context" + "encoding/json" + + "github.com/initializ/forge/forge-core/a2a" +) + +// ChannelPlugin is the interface every channel adapter must implement. +type ChannelPlugin interface { + // Name returns the adapter name (e.g. "slack", "telegram"). + Name() string + // Init configures the plugin from a ChannelConfig. + Init(cfg ChannelConfig) error + // Start begins listening for events and dispatching them to handler. + // It blocks until ctx is cancelled. + Start(ctx context.Context, handler EventHandler) error + // Stop gracefully shuts down the plugin. + Stop() error + // NormalizeEvent converts raw platform bytes into a ChannelEvent. + NormalizeEvent(raw []byte) (*ChannelEvent, error) + // SendResponse delivers an A2A response back to the originating platform. + SendResponse(event *ChannelEvent, response *a2a.Message) error +} + +// EventHandler is the callback signature provided by the router. +// The plugin calls it when a message arrives; the handler forwards the event +// to the A2A server and returns the agent's response. +type EventHandler func(ctx context.Context, event *ChannelEvent) (*a2a.Message, error) + +// ChannelConfig holds per-adapter configuration loaded from YAML. +type ChannelConfig struct { + Adapter string `yaml:"adapter"` + WebhookPort int `yaml:"webhook_port,omitempty"` + WebhookPath string `yaml:"webhook_path,omitempty"` + Settings map[string]string `yaml:"settings,omitempty"` +} + +// ChannelEvent is the normalized representation of an inbound message +// from any supported platform. +type ChannelEvent struct { + Channel string `json:"channel"` + WorkspaceID string `json:"workspace_id"` + UserID string `json:"user_id"` + ThreadID string `json:"thread_id,omitempty"` + Message string `json:"message"` + Attachments []Attachment `json:"attachments,omitempty"` + Raw json.RawMessage `json:"raw,omitempty"` +} + +// Attachment represents a file or media item attached to a channel message. +type Attachment struct { + Name string `json:"name,omitempty"` + MimeType string `json:"mime_type,omitempty"` + URL string `json:"url,omitempty"` +} diff --git a/forge-core/channels/registry.go b/forge-core/channels/registry.go new file mode 100644 index 0000000..cb83f0a --- /dev/null +++ b/forge-core/channels/registry.go @@ -0,0 +1,21 @@ +package channels + +// Registry holds registered channel plugins keyed by name. +type Registry struct { + plugins map[string]ChannelPlugin +} + +// NewRegistry creates an empty plugin registry. +func NewRegistry() *Registry { + return &Registry{plugins: make(map[string]ChannelPlugin)} +} + +// Register adds a plugin to the registry, keyed by its Name(). +func (r *Registry) Register(p ChannelPlugin) { + r.plugins[p.Name()] = p +} + +// Get returns the plugin with the given name, or nil if not found. +func (r *Registry) Get(name string) ChannelPlugin { + return r.plugins[name] +} diff --git a/forge-core/compiler/agentspec_gen.go b/forge-core/compiler/agentspec_gen.go new file mode 100644 index 0000000..689be5c --- /dev/null +++ b/forge-core/compiler/agentspec_gen.go @@ -0,0 +1,163 @@ +// Package compiler provides pure functions for generating and transforming AgentSpec data. +package compiler + +import ( + "encoding/json" + "path/filepath" + "strings" + + "github.com/initializ/forge/forge-core/agentspec" + "github.com/initializ/forge/forge-core/plugins" + "github.com/initializ/forge/forge-core/types" +) + +// ConfigToAgentSpec converts a ForgeConfig into an AgentSpec. +func ConfigToAgentSpec(cfg *types.ForgeConfig) *agentspec.AgentSpec { + spec := &agentspec.AgentSpec{ + ForgeVersion: "1.0", + AgentID: cfg.AgentID, + Version: cfg.Version, + Name: cfg.AgentID, + } + + fields := strings.Fields(cfg.Entrypoint) + spec.Runtime = &agentspec.RuntimeConfig{ + Image: InferBaseImage(fields), + Entrypoint: fields, + Port: 8080, + } + + for _, t := range cfg.Tools { + ts := agentspec.ToolSpec{Name: t.Name} + if t.Name == "cli_execute" && t.Config != nil { + meta := &agentspec.ForgeToolMeta{} + if bins, ok := t.Config["allowed_binaries"]; ok { + if binSlice, ok := bins.([]any); ok { + for _, b := range binSlice { + if s, ok := b.(string); ok { + meta.AllowedBinaries = append(meta.AllowedBinaries, s) + } + } + } + } + if envPass, ok := t.Config["env_passthrough"]; ok { + if envSlice, ok := envPass.([]any); ok { + for _, e := range envSlice { + if s, ok := e.(string); ok { + meta.EnvPassthrough = append(meta.EnvPassthrough, s) + } + } + } + } + ts.ForgeMeta = meta + } + spec.Tools = append(spec.Tools, ts) + } + + if cfg.Model.Provider != "" || cfg.Model.Name != "" { + spec.Model = &agentspec.ModelConfig{ + Provider: cfg.Model.Provider, + Name: cfg.Model.Name, + Version: cfg.Model.Version, + } + } + + if cfg.Egress.Profile != "" { + spec.EgressProfile = cfg.Egress.Profile + } + if cfg.Egress.Mode != "" { + spec.EgressMode = cfg.Egress.Mode + } + + return spec +} + +// InferBaseImage returns a container base image based on the entrypoint command. +func InferBaseImage(entrypoint []string) string { + if len(entrypoint) == 0 { + return "ubuntu:latest" + } + switch { + case strings.HasPrefix(entrypoint[0], "python"): + return "python:3.12-slim" + case entrypoint[0] == "bun": + return "oven/bun:latest" + case entrypoint[0] == "go" || entrypoint[0] == "./main": + return "golang:1.23-alpine" + case entrypoint[0] == "node": + return "node:20-slim" + default: + return "ubuntu:latest" + } +} + +// MergePluginConfig fills gaps in the spec with plugin-extracted values. +// forge.yaml values always take precedence. +func MergePluginConfig(spec *agentspec.AgentSpec, pc *plugins.AgentConfig) { + // Name: only fill if still equal to AgentID (i.e., not explicitly set) + if pc.Name != "" && spec.Name == spec.AgentID { + spec.Name = pc.Name + } + + // Description: only fill if empty + if pc.Description != "" && spec.Description == "" { + spec.Description = pc.Description + } + + // Tools: append new tools or enrich existing ones by name + existingTools := make(map[string]int, len(spec.Tools)) + for i, t := range spec.Tools { + existingTools[t.Name] = i + } + for _, pt := range pc.Tools { + if idx, ok := existingTools[pt.Name]; ok { + // Enrich existing tool + if spec.Tools[idx].Description == "" && pt.Description != "" { + spec.Tools[idx].Description = pt.Description + } + if spec.Tools[idx].InputSchema == nil && pt.InputSchema != nil { + data, err := json.Marshal(pt.InputSchema) + if err == nil { + spec.Tools[idx].InputSchema = data + } + } + } else { + // Append new tool + tool := agentspec.ToolSpec{ + Name: pt.Name, + Description: pt.Description, + } + if pt.InputSchema != nil { + data, err := json.Marshal(pt.InputSchema) + if err == nil { + tool.InputSchema = data + } + } + spec.Tools = append(spec.Tools, tool) + } + } + + // Model: only fill if not already set from forge.yaml + if pc.Model != nil && spec.Model == nil { + spec.Model = &agentspec.ModelConfig{ + Provider: pc.Model.Provider, + Name: pc.Model.Name, + Version: pc.Model.Version, + } + } +} + +// WrapperEntrypoint returns the entrypoint command for a generated wrapper file. +func WrapperEntrypoint(file string) []string { + ext := strings.ToLower(filepath.Ext(file)) + switch ext { + case ".py": + return []string{"python", file} + case ".ts": + return []string{"bun", "run", file} + case ".go": + return []string{"go", "run", file} + default: + return []string{"python", file} + } +} diff --git a/forge-core/compiler/template_data.go b/forge-core/compiler/template_data.go new file mode 100644 index 0000000..798dfe9 --- /dev/null +++ b/forge-core/compiler/template_data.go @@ -0,0 +1,117 @@ +package compiler + +import ( + "encoding/json" + + "github.com/initializ/forge/forge-core/agentspec" + "github.com/initializ/forge/forge-core/pipeline" +) + +// TemplateSpecData holds data used by Dockerfile and K8s templates. +type TemplateSpecData struct { + AgentID string + Version string + Runtime *TemplateRuntimeData + Registry string + NetworkPolicy *NetworkPolicyData + + // Container packaging extensions + EgressProfile string + EgressMode string + ToolInterfaceVersion string + SkillsCount int + HasSkills bool + DevBuild bool + ProdBuild bool + + // Skill requirements + RequiredEnvVars []string + OptionalEnvVars []string + RequiredBins []string +} + +// TemplateRuntimeData holds runtime-specific template data. +type TemplateRuntimeData struct { + Image string + Port int + Entrypoint string // Pre-formatted JSON array string, e.g. ["python", "agent.py"] + Env map[string]string + DepsFile string + DepsInstallCmd string + HealthCheck string + User string + ModelEnv map[string]string +} + +// NetworkPolicyData holds network policy template data. +type NetworkPolicyData struct { + DenyAll bool +} + +// BuildTemplateDataFromSpec creates template data from an AgentSpec. +func BuildTemplateDataFromSpec(spec *agentspec.AgentSpec) *TemplateSpecData { + d := &TemplateSpecData{ + AgentID: spec.AgentID, + Version: spec.Version, + NetworkPolicy: &NetworkPolicyData{DenyAll: true}, // default: deny all egress + } + if spec.Runtime != nil { + ep, _ := json.Marshal(spec.Runtime.Entrypoint) + env := spec.Runtime.Env + + // Build ModelEnv from model config + var modelEnv map[string]string + if spec.Model != nil && spec.Model.Provider != "" { + modelEnv = map[string]string{ + "FORGE_MODEL_PROVIDER": spec.Model.Provider, + } + if spec.Model.Name != "" { + modelEnv["FORGE_MODEL_NAME"] = spec.Model.Name + } + } + + // Merge ModelEnv into Env for deployment template (ModelEnv takes precedence) + if len(modelEnv) > 0 { + if env == nil { + env = make(map[string]string) + } + for k, v := range modelEnv { + env[k] = v + } + } + + d.Runtime = &TemplateRuntimeData{ + Image: spec.Runtime.Image, + Port: spec.Runtime.Port, + Entrypoint: string(ep), + Env: env, + DepsFile: spec.Runtime.DepsFile, + DepsInstallCmd: spec.Runtime.DepsInstallCmd, + HealthCheck: spec.Runtime.HealthCheck, + User: spec.Runtime.User, + ModelEnv: modelEnv, + } + } + return d +} + +// BuildTemplateDataFromContext creates template data from an AgentSpec and BuildContext. +func BuildTemplateDataFromContext(spec *agentspec.AgentSpec, bc *pipeline.BuildContext) *TemplateSpecData { + d := BuildTemplateDataFromSpec(spec) + d.DevBuild = bc.DevMode + d.ProdBuild = bc.ProdMode + d.SkillsCount = bc.SkillsCount + d.HasSkills = bc.SkillsCount > 0 + d.EgressProfile = spec.EgressProfile + d.EgressMode = spec.EgressMode + d.ToolInterfaceVersion = spec.ToolInterfaceVersion + + // Populate skill requirements from build context + if spec.Requirements != nil { + d.RequiredEnvVars = spec.Requirements.EnvRequired + d.OptionalEnvVars = spec.Requirements.EnvOptional + d.RequiredBins = spec.Requirements.Bins + } + + return d +} diff --git a/forge-core/compiler/tool_filter.go b/forge-core/compiler/tool_filter.go new file mode 100644 index 0000000..d004636 --- /dev/null +++ b/forge-core/compiler/tool_filter.go @@ -0,0 +1,74 @@ +package compiler + +import ( + "github.com/initializ/forge/forge-core/agentspec" +) + +// Known dev tools that should be filtered in production builds. +var knownDevTools = map[string]bool{ + "local_shell": true, + "local_file_browser": true, + "debug_console": true, + "test_runner": true, +} + +// Known builtin tools. +var knownBuiltinTools = map[string]bool{ + "web_search": true, + "web-search": true, + "http_request": true, + "code_interpreter": true, + "text_generation": true, + "cli_execute": true, +} + +// Known adapter tools. +var knownAdapterTools = map[string]bool{ + "slack_notify": true, + "github_api": true, + "sendgrid_email": true, + "twilio_sms": true, + "openai_completion": true, + "anthropic_api": true, + "huggingface_api": true, + "google_vertex": true, + "aws_bedrock": true, + "azure_openai": true, +} + +// AnnotateToolCategories sets the Category field on each tool based on known tool lists. +func AnnotateToolCategories(tools []agentspec.ToolSpec) { + for i := range tools { + name := tools[i].Name + switch { + case knownDevTools[name]: + tools[i].Category = "dev" + case knownBuiltinTools[name]: + tools[i].Category = "builtin" + case knownAdapterTools[name]: + tools[i].Category = "adapter" + default: + tools[i].Category = "custom" + } + } +} + +// FilterDevTools removes tools with category "dev" from the slice and returns the filtered result. +func FilterDevTools(tools []agentspec.ToolSpec) []agentspec.ToolSpec { + filtered := make([]agentspec.ToolSpec, 0, len(tools)) + for _, t := range tools { + if t.Category != "dev" { + filtered = append(filtered, t) + } + } + return filtered +} + +// CountToolCategories returns a map of category to count for the given tools. +func CountToolCategories(tools []agentspec.ToolSpec) map[string]int { + counts := make(map[string]int) + for _, t := range tools { + counts[t.Category]++ + } + return counts +} diff --git a/forge-core/export/export.go b/forge-core/export/export.go new file mode 100644 index 0000000..89c755c --- /dev/null +++ b/forge-core/export/export.go @@ -0,0 +1,161 @@ +// Package export provides pure logic for building Command platform export envelopes. +package export + +import ( + "encoding/json" + "time" + + "github.com/initializ/forge/forge-core/agentspec" +) + +// ExportMeta contains metadata added to the export envelope. +type ExportMeta struct { + ExportedAt string `json:"exported_at"` + ForgeCLIVersion string `json:"forge_cli_version"` + CompatibleCommandVersions []string `json:"compatible_command_versions"` + ToolCategories map[string]int `json:"tool_categories"` + SkillsCount int `json:"skills_count"` + EgressProfile string `json:"egress_profile,omitempty"` +} + +// SecurityBlock represents the security section of the export envelope. +type SecurityBlock struct { + Egress EgressBlock `json:"egress"` +} + +// EgressBlock represents egress settings in the security block. +type EgressBlock struct { + Profile string `json:"profile"` + Mode string `json:"mode"` + AllowedDomains []string `json:"allowed_domains,omitempty"` +} + +// NetworkPolicyBlock represents the network_policy section of the export envelope. +type NetworkPolicyBlock struct { + DefaultEgress string `json:"default_egress"` + AllowedDomains []string `json:"allowed_domains,omitempty"` +} + +// ExportValidation holds warnings generated during export validation. +type ExportValidation struct { + Warnings []string + Errors []string +} + +// ValidateForExport checks an AgentSpec for export-specific issues. +// devMode=true allows dev-category tools. +func ValidateForExport(spec *agentspec.AgentSpec, devMode bool) *ExportValidation { + v := &ExportValidation{} + + for _, tool := range spec.Tools { + if tool.Category == "dev" && !devMode { + v.Errors = append(v.Errors, "tool "+tool.Name+" has category \"dev\"; use --dev flag to include dev-category tools in export") + } + } + if spec.ToolInterfaceVersion == "" { + v.Warnings = append(v.Warnings, "tool_interface_version is empty") + } + for _, tool := range spec.Tools { + if len(tool.InputSchema) == 0 { + v.Warnings = append(v.Warnings, "tool "+tool.Name+" has empty input_schema (prompt-only tools won't map cleanly)") + } + } + if spec.EgressMode == "dev-open" { + v.Warnings = append(v.Warnings, "egress_mode is \"dev-open\" (not recommended for export)") + } + if spec.EgressProfile == "" && spec.EgressMode == "allowlist" { + v.Warnings = append(v.Warnings, "egress_profile is empty and egress_mode is \"allowlist\" (agent may not reach LLM)") + } + + return v +} + +// BuildEnvelope constructs the export envelope from an AgentSpec. +// allowlistDomains is the list of domains from egress_allowlist.json (can be nil). +// cliVersion is the forge CLI version string. +func BuildEnvelope(spec *agentspec.AgentSpec, allowlistDomains []string, cliVersion string) (map[string]any, error) { + // Marshal spec to JSON then unmarshal to map for envelope construction + specBytes, err := json.Marshal(spec) + if err != nil { + return nil, err + } + + var envelope map[string]any + if err := json.Unmarshal(specBytes, &envelope); err != nil { + return nil, err + } + + // Build tool category counts + toolCategories := map[string]int{} + for _, tool := range spec.Tools { + if tool.Category != "" { + toolCategories[tool.Category]++ + } + } + + skillsCount := 0 + if spec.A2A != nil { + skillsCount = len(spec.A2A.Skills) + } + + meta := map[string]any{ + "exported_at": time.Now().UTC().Format(time.RFC3339), + "forge_cli_version": cliVersion, + "compatible_command_versions": []string{">=1.0.0"}, + "tool_categories": toolCategories, + "skills_count": skillsCount, + } + if spec.EgressProfile != "" { + meta["egress_profile"] = spec.EgressProfile + } + envelope["_forge_export_meta"] = meta + + // Add security block + if spec.EgressProfile != "" || spec.EgressMode != "" { + security := map[string]any{ + "egress": map[string]any{ + "profile": spec.EgressProfile, + "mode": spec.EgressMode, + }, + } + if len(allowlistDomains) > 0 { + security["egress"].(map[string]any)["allowed_domains"] = allowlistDomains + } + envelope["security"] = security + } + + // Add network_policy block + if spec.EgressMode != "" { + np := map[string]any{ + "default_egress": "deny", + } + if spec.EgressMode == "allowlist" && len(allowlistDomains) > 0 { + np["allowed_domains"] = allowlistDomains + } + envelope["network_policy"] = np + } + + return envelope, nil +} + +// KnownDevTools lists tool names that should be filtered in production builds. +var KnownDevTools = map[string]bool{ + "local_shell": true, + "local_file_browser": true, + "debug_console": true, + "test_runner": true, +} + +// ValidateProdConfig checks that a config is valid for production builds. +func ValidateProdConfig(egressMode string, toolNames []string) *ExportValidation { + v := &ExportValidation{} + if egressMode == "dev-open" { + v.Errors = append(v.Errors, "egress mode 'dev-open' is not allowed in production builds") + } + for _, name := range toolNames { + if KnownDevTools[name] { + v.Errors = append(v.Errors, "dev tool "+name+" is not allowed in production builds") + } + } + return v +} diff --git a/forge-core/export/export_test.go b/forge-core/export/export_test.go new file mode 100644 index 0000000..ed690e9 --- /dev/null +++ b/forge-core/export/export_test.go @@ -0,0 +1,194 @@ +package export + +import ( + "encoding/json" + "testing" + + "github.com/initializ/forge/forge-core/agentspec" +) + +func TestValidateForExport_DevTool(t *testing.T) { + spec := &agentspec.AgentSpec{ + Tools: []agentspec.ToolSpec{ + {Name: "local_shell", Category: "dev"}, + }, + } + + v := ValidateForExport(spec, false) + if len(v.Errors) == 0 { + t.Error("expected error for dev tool in non-dev mode") + } + + v = ValidateForExport(spec, true) + if len(v.Errors) > 0 { + t.Error("expected no errors for dev tool in dev mode") + } +} + +func TestValidateForExport_Warnings(t *testing.T) { + spec := &agentspec.AgentSpec{ + EgressMode: "dev-open", + Tools: []agentspec.ToolSpec{ + {Name: "http_request"}, + }, + } + + v := ValidateForExport(spec, true) + if len(v.Warnings) == 0 { + t.Error("expected warnings for dev-open egress and empty tool_interface_version") + } +} + +func TestBuildEnvelope_Basic(t *testing.T) { + spec := &agentspec.AgentSpec{ + AgentID: "test-agent", + Version: "1.0", + ForgeVersion: "1.0", + Name: "Test Agent", + Tools: []agentspec.ToolSpec{ + {Name: "http_request", Category: "builtin"}, + {Name: "my_tool", Category: "custom"}, + }, + EgressProfile: "strict", + EgressMode: "allowlist", + } + + envelope, err := BuildEnvelope(spec, []string{"api.openai.com"}, "0.1.0") + if err != nil { + t.Fatalf("BuildEnvelope() error: %v", err) + } + + // Check meta + meta, ok := envelope["_forge_export_meta"].(map[string]any) + if !ok { + t.Fatal("missing _forge_export_meta") + } + if meta["forge_cli_version"] != "0.1.0" { + t.Errorf("forge_cli_version = %v, want 0.1.0", meta["forge_cli_version"]) + } + + cats, ok := meta["tool_categories"].(map[string]int) + if !ok { + t.Fatal("missing tool_categories") + } + if cats["builtin"] != 1 || cats["custom"] != 1 { + t.Errorf("tool_categories = %v", cats) + } + + // Check security block + sec, ok := envelope["security"].(map[string]any) + if !ok { + t.Fatal("missing security block") + } + egress := sec["egress"].(map[string]any) + if egress["profile"] != "strict" { + t.Errorf("egress.profile = %v, want strict", egress["profile"]) + } + domains, ok := egress["allowed_domains"].([]string) + if !ok || len(domains) != 1 || domains[0] != "api.openai.com" { + t.Errorf("egress.allowed_domains = %v", egress["allowed_domains"]) + } + + // Check network_policy block + np, ok := envelope["network_policy"].(map[string]any) + if !ok { + t.Fatal("missing network_policy block") + } + if np["default_egress"] != "deny" { + t.Errorf("network_policy.default_egress = %v", np["default_egress"]) + } +} + +func TestBuildEnvelope_NoEgress(t *testing.T) { + spec := &agentspec.AgentSpec{ + AgentID: "test-agent", + Version: "1.0", + ForgeVersion: "1.0", + Name: "Test Agent", + } + + envelope, err := BuildEnvelope(spec, nil, "0.1.0") + if err != nil { + t.Fatalf("BuildEnvelope() error: %v", err) + } + + if _, ok := envelope["security"]; ok { + t.Error("should not have security block when no egress config") + } + if _, ok := envelope["network_policy"]; ok { + t.Error("should not have network_policy block when no egress config") + } +} + +func TestBuildEnvelope_WithSkills(t *testing.T) { + spec := &agentspec.AgentSpec{ + AgentID: "test-agent", + Version: "1.0", + ForgeVersion: "1.0", + Name: "Test Agent", + A2A: &agentspec.A2AConfig{ + Skills: []agentspec.A2ASkill{ + {ID: "skill-1", Name: "Skill 1"}, + {ID: "skill-2", Name: "Skill 2"}, + }, + }, + } + + envelope, err := BuildEnvelope(spec, nil, "0.1.0") + if err != nil { + t.Fatalf("BuildEnvelope() error: %v", err) + } + + meta := envelope["_forge_export_meta"].(map[string]any) + count, _ := meta["skills_count"].(int) + if count != 2 { + t.Errorf("skills_count = %v, want 2", meta["skills_count"]) + } +} + +func TestBuildEnvelope_Roundtrip(t *testing.T) { + spec := &agentspec.AgentSpec{ + AgentID: "roundtrip-agent", + Version: "2.0", + ForgeVersion: "1.1", + Name: "Roundtrip", + EgressProfile: "standard", + EgressMode: "allowlist", + } + + envelope, err := BuildEnvelope(spec, []string{"example.com"}, "0.2.0") + if err != nil { + t.Fatalf("BuildEnvelope() error: %v", err) + } + + // Should be marshalable to JSON + data, err := json.MarshalIndent(envelope, "", " ") + if err != nil { + t.Fatalf("json.Marshal error: %v", err) + } + + if len(data) == 0 { + t.Error("exported JSON is empty") + } +} + +func TestValidateProdConfig_DevOpen(t *testing.T) { + v := ValidateProdConfig("dev-open", nil) + if len(v.Errors) == 0 { + t.Error("expected error for dev-open egress mode") + } +} + +func TestValidateProdConfig_DevTool(t *testing.T) { + v := ValidateProdConfig("allowlist", []string{"http_request", "local_shell"}) + if len(v.Errors) == 0 { + t.Error("expected error for dev tool local_shell") + } +} + +func TestValidateProdConfig_Clean(t *testing.T) { + v := ValidateProdConfig("allowlist", []string{"http_request", "web_search"}) + if len(v.Errors) > 0 { + t.Errorf("expected no errors, got: %v", v.Errors) + } +} diff --git a/forge-core/forgecore.go b/forge-core/forgecore.go new file mode 100644 index 0000000..4cdd4c2 --- /dev/null +++ b/forge-core/forgecore.go @@ -0,0 +1,135 @@ +// Package forgecore provides a high-level API surface for embedding +// Forge's compiler, validator, and runtime engine as a library. +// +// This is the primary entry point for external consumers (e.g. Command) +// who want to use Forge's capabilities without importing CLI dependencies. +package forgecore + +import ( + "github.com/initializ/forge/forge-core/agentspec" + "github.com/initializ/forge/forge-core/compiler" + "github.com/initializ/forge/forge-core/llm" + "github.com/initializ/forge/forge-core/plugins" + "github.com/initializ/forge/forge-core/runtime" + "github.com/initializ/forge/forge-core/security" + "github.com/initializ/forge/forge-core/skills" + "github.com/initializ/forge/forge-core/types" + "github.com/initializ/forge/forge-core/validate" +) + +// ─── Compile API ────────────────────────────────────────────────────── + +// CompileRequest contains the inputs for compiling a ForgeConfig into an AgentSpec. +type CompileRequest struct { + Config *types.ForgeConfig + PluginConfig *plugins.AgentConfig // optional framework plugin config + SkillEntries []skills.SkillEntry // optional skill entries +} + +// CompileResult contains the outputs of a successful compilation. +type CompileResult struct { + Spec *agentspec.AgentSpec + CompiledSkills *skills.CompiledSkills // nil if no skills + EgressConfig *security.EgressConfig + Allowlist []byte // JSON-encoded allowlist +} + +// Compile transforms a ForgeConfig into a fully-resolved AgentSpec with +// security configuration, skill compilation, and optional plugin merging. +func Compile(req CompileRequest) (*CompileResult, error) { + spec := compiler.ConfigToAgentSpec(req.Config) + + // Merge plugin configuration if provided + if req.PluginConfig != nil { + compiler.MergePluginConfig(spec, req.PluginConfig) + } + + // Compile skills if provided + var cs *skills.CompiledSkills + if len(req.SkillEntries) > 0 { + var err error + cs, err = skills.Compile(req.SkillEntries) + if err != nil { + return nil, err + } + } + + // Resolve egress configuration + var toolNames []string + for _, t := range spec.Tools { + toolNames = append(toolNames, t.Name) + } + + egressCfg, err := security.Resolve( + req.Config.Egress.Profile, + req.Config.Egress.Mode, + req.Config.Egress.AllowedDomains, + toolNames, + nil, // capabilities resolved from profile + ) + if err != nil { + return nil, err + } + + allowlist, err2 := security.GenerateAllowlistJSON(egressCfg) + if err2 != nil { + return nil, err2 + } + + // Map egress to spec fields + spec.EgressProfile = string(egressCfg.Profile) + spec.EgressMode = string(egressCfg.Mode) + + return &CompileResult{ + Spec: spec, + CompiledSkills: cs, + EgressConfig: egressCfg, + Allowlist: allowlist, + }, nil +} + +// ─── Validate API ───────────────────────────────────────────────────── + +// ValidateConfig checks a ForgeConfig for errors and warnings. +func ValidateConfig(cfg *types.ForgeConfig) *validate.ValidationResult { + return validate.ValidateForgeConfig(cfg) +} + +// ValidateAgentSpec validates raw JSON bytes against the AgentSpec v1.0 schema. +func ValidateAgentSpec(jsonData []byte) ([]string, error) { + return validate.ValidateAgentSpec(jsonData) +} + +// ValidateCommandCompat checks an AgentSpec against Command platform requirements. +func ValidateCommandCompat(spec *agentspec.AgentSpec) *validate.ValidationResult { + return validate.ValidateCommandCompat(spec) +} + +// SimulateImport simulates what Command's import API would produce from an AgentSpec. +func SimulateImport(spec *agentspec.AgentSpec) *validate.ImportSimResult { + return validate.SimulateImport(spec) +} + +// ─── Runtime API ────────────────────────────────────────────────────── + +// RuntimeConfig configures the LLM agent runtime. +type RuntimeConfig struct { + LLMClient llm.Client + Tools runtime.ToolExecutor + Hooks *runtime.HookRegistry + SystemPrompt string + MaxIterations int + Guardrails *runtime.GuardrailEngine // optional + Logger runtime.Logger // optional +} + +// NewRuntime creates a new LLMExecutor configured for agent execution. +func NewRuntime(cfg RuntimeConfig) *runtime.LLMExecutor { + return runtime.NewLLMExecutor(runtime.LLMExecutorConfig{ + Client: cfg.LLMClient, + Tools: cfg.Tools, + Hooks: cfg.Hooks, + SystemPrompt: cfg.SystemPrompt, + MaxIterations: cfg.MaxIterations, + }) +} diff --git a/forge-core/forgecore_test.go b/forge-core/forgecore_test.go new file mode 100644 index 0000000..cfc92d0 --- /dev/null +++ b/forge-core/forgecore_test.go @@ -0,0 +1,1401 @@ +package forgecore + +import ( + "context" + "encoding/json" + "testing" + + "github.com/initializ/forge/forge-core/a2a" + "github.com/initializ/forge/forge-core/agentspec" + "github.com/initializ/forge/forge-core/llm" + "github.com/initializ/forge/forge-core/runtime" + "github.com/initializ/forge/forge-core/skills" + "github.com/initializ/forge/forge-core/types" +) + +// ─── Mock LLM Client ───────────────────────────────────────────────── + +type mockLLMClient struct { + response *llm.ChatResponse + err error + calls int +} + +func (m *mockLLMClient) Chat(ctx context.Context, req *llm.ChatRequest) (*llm.ChatResponse, error) { + m.calls++ + if m.err != nil { + return nil, m.err + } + return m.response, nil +} + +func (m *mockLLMClient) ChatStream(ctx context.Context, req *llm.ChatRequest) (<-chan llm.StreamDelta, error) { + ch := make(chan llm.StreamDelta, 1) + close(ch) + return ch, nil +} + +func (m *mockLLMClient) ModelID() string { return "mock-model" } + +// ─── Mock Tool Executor ────────────────────────────────────────────── + +type mockToolExecutor struct { + tools map[string]string // name -> fixed response +} + +func newMockToolExecutor(tools map[string]string) *mockToolExecutor { + return &mockToolExecutor{tools: tools} +} + +func (m *mockToolExecutor) Execute(ctx context.Context, name string, arguments json.RawMessage) (string, error) { + if resp, ok := m.tools[name]; ok { + return resp, nil + } + return "", nil +} + +func (m *mockToolExecutor) ToolDefinitions() []llm.ToolDefinition { + var defs []llm.ToolDefinition + for name := range m.tools { + defs = append(defs, llm.ToolDefinition{ + Type: "function", + Function: llm.FunctionSchema{ + Name: name, + Description: "mock tool " + name, + }, + }) + } + return defs +} + +// ─── Sequential Mock Client ───────────────────────────────────────── + +type sequentialMockClient struct { + responses []*llm.ChatResponse + callIdx int +} + +func (m *sequentialMockClient) Chat(ctx context.Context, req *llm.ChatRequest) (*llm.ChatResponse, error) { + if m.callIdx >= len(m.responses) { + return &llm.ChatResponse{ + Message: llm.ChatMessage{Role: llm.RoleAssistant, Content: "fallback"}, + FinishReason: "stop", + }, nil + } + resp := m.responses[m.callIdx] + m.callIdx++ + return resp, nil +} + +func (m *sequentialMockClient) ChatStream(ctx context.Context, req *llm.ChatRequest) (<-chan llm.StreamDelta, error) { + ch := make(chan llm.StreamDelta, 1) + close(ch) + return ch, nil +} + +func (m *sequentialMockClient) ModelID() string { return "sequential-mock" } + +// ─── Compile Tests ─────────────────────────────────────────────────── + +func TestCompile_BasicConfig(t *testing.T) { + cfg := &types.ForgeConfig{ + AgentID: "test-agent", + Version: "1.0.0", + Framework: "custom", + Entrypoint: "python main.py", + Model: types.ModelRef{ + Provider: "openai", + Name: "gpt-4", + }, + Tools: []types.ToolRef{ + {Name: "http_request", Type: "builtin"}, + }, + Egress: types.EgressRef{ + Profile: "strict", + Mode: "allowlist", + AllowedDomains: []string{"api.openai.com"}, + }, + } + + result, err := Compile(CompileRequest{Config: cfg}) + if err != nil { + t.Fatalf("Compile() error: %v", err) + } + + if result.Spec == nil { + t.Fatal("Compile() returned nil Spec") + } + if result.Spec.AgentID != "test-agent" { + t.Errorf("AgentID = %q, want test-agent", result.Spec.AgentID) + } + if result.Spec.Version != "1.0.0" { + t.Errorf("Version = %q, want 1.0.0", result.Spec.Version) + } + if result.Spec.ForgeVersion != "1.0" { + t.Errorf("ForgeVersion = %q, want 1.0", result.Spec.ForgeVersion) + } + if result.Spec.Model == nil { + t.Fatal("Compile() returned nil Model in spec") + } + if result.Spec.Model.Provider != "openai" { + t.Errorf("Model.Provider = %q, want openai", result.Spec.Model.Provider) + } + if result.Spec.Model.Name != "gpt-4" { + t.Errorf("Model.Name = %q, want gpt-4", result.Spec.Model.Name) + } + if len(result.Spec.Tools) != 1 { + t.Fatalf("got %d tools, want 1", len(result.Spec.Tools)) + } + if result.Spec.Tools[0].Name != "http_request" { + t.Errorf("Tool[0].Name = %q, want http_request", result.Spec.Tools[0].Name) + } + if result.EgressConfig == nil { + t.Fatal("Compile() returned nil EgressConfig") + } + if len(result.Allowlist) == 0 { + t.Error("Compile() returned empty Allowlist") + } + if result.Spec.EgressProfile != "strict" { + t.Errorf("EgressProfile = %q, want strict", result.Spec.EgressProfile) + } + if result.Spec.EgressMode != "allowlist" { + t.Errorf("EgressMode = %q, want allowlist", result.Spec.EgressMode) + } +} + +func TestCompile_WithSkills(t *testing.T) { + cfg := &types.ForgeConfig{ + AgentID: "skill-agent", + Version: "1.0.0", + Framework: "custom", + Entrypoint: "python main.py", + Egress: types.EgressRef{ + Profile: "strict", + Mode: "deny-all", + }, + } + + entries := []skills.SkillEntry{ + { + Name: "summarize", + Description: "Summarize text content", + InputSpec: "text: string", + OutputSpec: "summary: string", + }, + { + Name: "translate", + Description: "Translate text to another language", + InputSpec: "text: string, target_lang: string", + OutputSpec: "translated: string", + }, + } + + result, err := Compile(CompileRequest{ + Config: cfg, + SkillEntries: entries, + }) + if err != nil { + t.Fatalf("Compile() error: %v", err) + } + + if result.CompiledSkills == nil { + t.Fatal("Compile() returned nil CompiledSkills") + } + if len(result.CompiledSkills.Skills) != 2 { + t.Errorf("got %d compiled skills, want 2", len(result.CompiledSkills.Skills)) + } + if result.CompiledSkills.Count != 2 { + t.Errorf("CompiledSkills.Count = %d, want 2", result.CompiledSkills.Count) + } + if result.CompiledSkills.Version != "agentskills-v1" { + t.Errorf("CompiledSkills.Version = %q, want agentskills-v1", result.CompiledSkills.Version) + } + + // Verify skill names + foundSummarize, foundTranslate := false, false + for _, s := range result.CompiledSkills.Skills { + switch s.Name { + case "summarize": + foundSummarize = true + case "translate": + foundTranslate = true + } + } + if !foundSummarize { + t.Error("missing compiled skill 'summarize'") + } + if !foundTranslate { + t.Error("missing compiled skill 'translate'") + } +} + +func TestCompile_NoEgress(t *testing.T) { + cfg := &types.ForgeConfig{ + AgentID: "no-egress", + Version: "1.0.0", + Framework: "custom", + Entrypoint: "python main.py", + } + + result, err := Compile(CompileRequest{Config: cfg}) + if err != nil { + t.Fatalf("Compile() error: %v", err) + } + + if result.Spec == nil { + t.Fatal("Compile() returned nil Spec") + } + // Default egress should be resolved (strict / deny-all) + if result.EgressConfig == nil { + t.Fatal("Compile() returned nil EgressConfig even with defaults") + } + if result.EgressConfig.Profile != "strict" { + t.Errorf("default EgressConfig.Profile = %q, want strict", result.EgressConfig.Profile) + } + if result.EgressConfig.Mode != "deny-all" { + t.Errorf("default EgressConfig.Mode = %q, want deny-all", result.EgressConfig.Mode) + } + if result.Spec.EgressProfile != "strict" { + t.Errorf("default Spec.EgressProfile = %q, want strict", result.Spec.EgressProfile) + } + if result.Spec.EgressMode != "deny-all" { + t.Errorf("default Spec.EgressMode = %q, want deny-all", result.Spec.EgressMode) + } +} + +func TestCompile_NoSkills(t *testing.T) { + cfg := &types.ForgeConfig{ + AgentID: "no-skills", + Version: "1.0.0", + Framework: "custom", + Entrypoint: "python main.py", + } + + result, err := Compile(CompileRequest{Config: cfg}) + if err != nil { + t.Fatalf("Compile() error: %v", err) + } + + if result.CompiledSkills != nil { + t.Error("expected nil CompiledSkills when no skill entries provided") + } +} + +func TestCompile_RuntimeInferred(t *testing.T) { + // Python entrypoint -> python:3.12-slim image + cfg := &types.ForgeConfig{ + AgentID: "runtime-test", + Version: "1.0.0", + Framework: "custom", + Entrypoint: "python main.py", + } + + result, err := Compile(CompileRequest{Config: cfg}) + if err != nil { + t.Fatalf("Compile() error: %v", err) + } + + if result.Spec.Runtime == nil { + t.Fatal("expected non-nil Runtime") + } + if result.Spec.Runtime.Image != "python:3.12-slim" { + t.Errorf("Runtime.Image = %q, want python:3.12-slim", result.Spec.Runtime.Image) + } + if result.Spec.Runtime.Port != 8080 { + t.Errorf("Runtime.Port = %d, want 8080", result.Spec.Runtime.Port) + } +} + +func TestCompile_NodeEntrypoint(t *testing.T) { + cfg := &types.ForgeConfig{ + AgentID: "node-agent", + Version: "1.0.0", + Framework: "custom", + Entrypoint: "node index.js", + } + + result, err := Compile(CompileRequest{Config: cfg}) + if err != nil { + t.Fatalf("Compile() error: %v", err) + } + + if result.Spec.Runtime.Image != "node:20-slim" { + t.Errorf("Runtime.Image = %q, want node:20-slim", result.Spec.Runtime.Image) + } +} + +func TestCompile_InvalidEgressProfile(t *testing.T) { + cfg := &types.ForgeConfig{ + AgentID: "bad-egress", + Version: "1.0.0", + Framework: "custom", + Entrypoint: "python main.py", + Egress: types.EgressRef{ + Profile: "invalid-profile", + }, + } + + _, err := Compile(CompileRequest{Config: cfg}) + if err == nil { + t.Fatal("expected error for invalid egress profile") + } +} + +// ─── Validate Tests ────────────────────────────────────────────────── + +func TestValidateConfig_Valid(t *testing.T) { + cfg := &types.ForgeConfig{ + AgentID: "valid-agent", + Version: "1.0.0", + Framework: "custom", + Entrypoint: "python main.py", + } + + result := ValidateConfig(cfg) + if !result.IsValid() { + t.Errorf("ValidateConfig() not valid: errors=%v", result.Errors) + } +} + +func TestValidateConfig_MissingFields(t *testing.T) { + cfg := &types.ForgeConfig{} + result := ValidateConfig(cfg) + if result.IsValid() { + t.Error("ValidateConfig() should fail for empty config") + } + // Should have errors for agent_id, version, entrypoint + if len(result.Errors) < 3 { + t.Errorf("expected at least 3 errors, got %d: %v", len(result.Errors), result.Errors) + } +} + +func TestValidateConfig_InvalidAgentID(t *testing.T) { + cfg := &types.ForgeConfig{ + AgentID: "Invalid_Agent_ID!", + Version: "1.0.0", + Framework: "custom", + Entrypoint: "python main.py", + } + + result := ValidateConfig(cfg) + if result.IsValid() { + t.Error("ValidateConfig() should fail for invalid agent_id format") + } +} + +func TestValidateConfig_InvalidSemver(t *testing.T) { + cfg := &types.ForgeConfig{ + AgentID: "test-agent", + Version: "not-a-version", + Framework: "custom", + Entrypoint: "python main.py", + } + + result := ValidateConfig(cfg) + if result.IsValid() { + t.Error("ValidateConfig() should fail for invalid semver") + } +} + +func TestValidateConfig_UnknownFramework(t *testing.T) { + cfg := &types.ForgeConfig{ + AgentID: "test-agent", + Version: "1.0.0", + Framework: "unknown-framework", + Entrypoint: "python main.py", + } + + result := ValidateConfig(cfg) + // Unknown framework is a warning, not an error + if !result.IsValid() { + t.Errorf("expected valid (warnings only) for unknown framework, got errors: %v", result.Errors) + } + if len(result.Warnings) == 0 { + t.Error("expected warning for unknown framework") + } +} + +func TestValidateConfig_InvalidEgressProfile(t *testing.T) { + cfg := &types.ForgeConfig{ + AgentID: "test-agent", + Version: "1.0.0", + Framework: "custom", + Entrypoint: "python main.py", + Egress: types.EgressRef{ + Profile: "bad-profile", + }, + } + + result := ValidateConfig(cfg) + if result.IsValid() { + t.Error("ValidateConfig() should fail for invalid egress profile") + } +} + +func TestValidateConfig_DevOpenWarning(t *testing.T) { + cfg := &types.ForgeConfig{ + AgentID: "test-agent", + Version: "1.0.0", + Framework: "custom", + Entrypoint: "python main.py", + Egress: types.EgressRef{ + Mode: "dev-open", + }, + } + + result := ValidateConfig(cfg) + if len(result.Warnings) == 0 { + t.Error("expected warning for dev-open mode") + } +} + +func TestValidateAgentSpec_ValidJSON(t *testing.T) { + spec := &agentspec.AgentSpec{ + ForgeVersion: "1.0", + AgentID: "test-agent", + Version: "1.0.0", + Name: "Test Agent", + Runtime: &agentspec.RuntimeConfig{ + Image: "python:3.12-slim", + Port: 8080, + }, + } + + data, err := json.Marshal(spec) + if err != nil { + t.Fatalf("json.Marshal error: %v", err) + } + errs, err := ValidateAgentSpec(data) + if err != nil { + t.Fatalf("ValidateAgentSpec() error: %v", err) + } + if len(errs) > 0 { + t.Errorf("ValidateAgentSpec() errors: %v", errs) + } +} + +func TestValidateAgentSpec_InvalidJSON(t *testing.T) { + _, err := ValidateAgentSpec([]byte(`{invalid json`)) + if err == nil { + t.Error("expected error for invalid JSON") + } +} + +func TestValidateCommandCompat_Valid(t *testing.T) { + spec := &agentspec.AgentSpec{ + ForgeVersion: "1.0", + AgentID: "test-agent", + Version: "1.0.0", + Name: "Test Agent", + Runtime: &agentspec.RuntimeConfig{ + Image: "python:3.12-slim", + }, + } + + result := ValidateCommandCompat(spec) + if !result.IsValid() { + t.Errorf("ValidateCommandCompat() not valid: errors=%v", result.Errors) + } +} + +func TestValidateCommandCompat_MissingRequired(t *testing.T) { + spec := &agentspec.AgentSpec{} // all fields empty + + result := ValidateCommandCompat(spec) + if result.IsValid() { + t.Error("expected validation failure for empty spec") + } + // Should have errors for agent_id, name, version, forge_version, runtime + if len(result.Errors) < 4 { + t.Errorf("expected at least 4 errors, got %d: %v", len(result.Errors), result.Errors) + } +} + +func TestValidateCommandCompat_UnsupportedVersion(t *testing.T) { + spec := &agentspec.AgentSpec{ + ForgeVersion: "99.0", + AgentID: "test-agent", + Version: "1.0.0", + Name: "Test Agent", + Runtime: &agentspec.RuntimeConfig{ + Image: "python:3.12-slim", + }, + } + + result := ValidateCommandCompat(spec) + if result.IsValid() { + t.Error("expected validation failure for unsupported forge version") + } +} + +func TestValidateCommandCompat_MissingRuntimeImage(t *testing.T) { + spec := &agentspec.AgentSpec{ + ForgeVersion: "1.0", + AgentID: "test-agent", + Version: "1.0.0", + Name: "Test Agent", + Runtime: &agentspec.RuntimeConfig{ + // Image intentionally empty + Port: 8080, + }, + } + + result := ValidateCommandCompat(spec) + if result.IsValid() { + t.Error("expected validation failure for missing runtime.image") + } +} + +func TestValidateCommandCompat_WarningsForOptional(t *testing.T) { + spec := &agentspec.AgentSpec{ + ForgeVersion: "1.0", + AgentID: "test-agent", + Version: "1.0.0", + Name: "Test Agent", + Runtime: &agentspec.RuntimeConfig{ + Image: "python:3.12-slim", + }, + // No A2A, no Model, no tool_interface_version + } + + result := ValidateCommandCompat(spec) + if !result.IsValid() { + t.Errorf("expected valid with warnings, got errors: %v", result.Errors) + } + if len(result.Warnings) == 0 { + t.Error("expected warnings for missing optional fields") + } +} + +// ─── SimulateImport Tests ──────────────────────────────────────────── + +func TestSimulateImport(t *testing.T) { + spec := &agentspec.AgentSpec{ + ForgeVersion: "1.0", + AgentID: "sim-agent", + Version: "1.0.0", + Name: "Sim Agent", + Description: "A test agent for simulation", + Runtime: &agentspec.RuntimeConfig{ + Image: "python:3.12-slim", + Port: 8080, + }, + Model: &agentspec.ModelConfig{ + Provider: "openai", + Name: "gpt-4", + }, + Tools: []agentspec.ToolSpec{ + {Name: "http_request", Category: "builtin"}, + }, + } + + result := SimulateImport(spec) + if result.Definition == nil { + t.Fatal("SimulateImport() returned nil Definition") + } + if result.Definition.Slug != "sim-agent" { + t.Errorf("Slug = %q, want sim-agent", result.Definition.Slug) + } + if result.Definition.DisplayName != "Sim Agent" { + t.Errorf("DisplayName = %q, want Sim Agent", result.Definition.DisplayName) + } + if result.Definition.Description != "A test agent for simulation" { + t.Errorf("Description = %q, want 'A test agent for simulation'", result.Definition.Description) + } + if result.Definition.ContainerImage != "python:3.12-slim" { + t.Errorf("ContainerImage = %q, want python:3.12-slim", result.Definition.ContainerImage) + } + if result.Definition.Port != 8080 { + t.Errorf("Port = %d, want 8080", result.Definition.Port) + } + if result.Definition.ModelProvider != "openai" { + t.Errorf("ModelProvider = %q, want openai", result.Definition.ModelProvider) + } + if result.Definition.ModelName != "gpt-4" { + t.Errorf("ModelName = %q, want gpt-4", result.Definition.ModelName) + } + if len(result.Definition.Tools) != 1 { + t.Fatalf("got %d tools, want 1", len(result.Definition.Tools)) + } + if result.Definition.Tools[0].Name != "http_request" { + t.Errorf("Tool[0].Name = %q, want http_request", result.Definition.Tools[0].Name) + } + if result.Definition.Tools[0].Category != "builtin" { + t.Errorf("Tool[0].Category = %q, want builtin", result.Definition.Tools[0].Category) + } +} + +func TestSimulateImport_NoRuntime(t *testing.T) { + spec := &agentspec.AgentSpec{ + ForgeVersion: "1.0", + AgentID: "no-runtime", + Version: "1.0.0", + Name: "No Runtime", + } + + result := SimulateImport(spec) + if result.Definition.ContainerImage != "" { + t.Errorf("expected empty ContainerImage, got %q", result.Definition.ContainerImage) + } + // Should have warning about no runtime + found := false + for _, w := range result.ImportWarnings { + if w == "no runtime config; container image will not be set" { + found = true + break + } + } + if !found { + t.Error("expected import warning about missing runtime") + } +} + +func TestSimulateImport_NoModel(t *testing.T) { + spec := &agentspec.AgentSpec{ + ForgeVersion: "1.0", + AgentID: "no-model", + Version: "1.0.0", + Name: "No Model", + Runtime: &agentspec.RuntimeConfig{ + Image: "python:3.12-slim", + }, + } + + result := SimulateImport(spec) + if result.Definition.ModelProvider != "" { + t.Errorf("expected empty ModelProvider, got %q", result.Definition.ModelProvider) + } + found := false + for _, w := range result.ImportWarnings { + if w == "no model config; model will not be configured" { + found = true + break + } + } + if !found { + t.Error("expected import warning about missing model") + } +} + +func TestSimulateImport_WithA2ASkills(t *testing.T) { + spec := &agentspec.AgentSpec{ + ForgeVersion: "1.0", + AgentID: "skill-sim", + Version: "1.0.0", + Name: "Skill Sim", + Runtime: &agentspec.RuntimeConfig{ + Image: "python:3.12-slim", + }, + A2A: &agentspec.A2AConfig{ + Skills: []agentspec.A2ASkill{ + {ID: "summarize", Name: "Summarize"}, + {ID: "translate", Name: "Translate"}, + }, + Capabilities: &agentspec.A2ACapabilities{ + Streaming: true, + PushNotifications: false, + }, + }, + } + + result := SimulateImport(spec) + if len(result.Definition.Skills) != 2 { + t.Errorf("got %d skills, want 2", len(result.Definition.Skills)) + } + if result.Definition.Capabilities == nil { + t.Fatal("expected non-nil Capabilities") + } + if !result.Definition.Capabilities.Streaming { + t.Error("expected Streaming = true") + } +} + +// ─── Runtime Tests ─────────────────────────────────────────────────── + +func TestNewRuntime_Basic(t *testing.T) { + client := &mockLLMClient{ + response: &llm.ChatResponse{ + Message: llm.ChatMessage{ + Role: llm.RoleAssistant, + Content: "Hello! I'm a test agent.", + }, + FinishReason: "stop", + }, + } + + tools := newMockToolExecutor(map[string]string{ + "http_request": `{"status": 200, "body": "ok"}`, + }) + + executor := NewRuntime(RuntimeConfig{ + LLMClient: client, + Tools: tools, + SystemPrompt: "You are a test agent.", + MaxIterations: 5, + }) + + if executor == nil { + t.Fatal("NewRuntime() returned nil") + } + + // Execute a simple message + task := &a2a.Task{ID: "test-task-1"} + msg := &a2a.Message{ + Role: a2a.MessageRoleUser, + Parts: []a2a.Part{a2a.NewTextPart("Hello")}, + } + + resp, err := executor.Execute(context.Background(), task, msg) + if err != nil { + t.Fatalf("Execute() error: %v", err) + } + if resp == nil { + t.Fatal("Execute() returned nil response") + } + if resp.Role != a2a.MessageRoleAgent { + t.Errorf("response Role = %q, want agent", resp.Role) + } + if len(resp.Parts) == 0 { + t.Fatal("Execute() returned empty parts") + } + if resp.Parts[0].Text != "Hello! I'm a test agent." { + t.Errorf("response text = %q, want 'Hello! I'm a test agent.'", resp.Parts[0].Text) + } + + // Check that LLM was called + if client.calls != 1 { + t.Errorf("LLM was called %d times, want 1", client.calls) + } +} + +func TestNewRuntime_WithToolCalling(t *testing.T) { + toolCallClient := &sequentialMockClient{ + responses: []*llm.ChatResponse{ + { + Message: llm.ChatMessage{ + Role: llm.RoleAssistant, + ToolCalls: []llm.ToolCall{ + { + ID: "call-1", + Type: "function", + Function: llm.FunctionCall{ + Name: "http_request", + Arguments: `{"url": "https://example.com"}`, + }, + }, + }, + }, + FinishReason: "tool_calls", + }, + { + Message: llm.ChatMessage{ + Role: llm.RoleAssistant, + Content: "I fetched the URL and got: ok", + }, + FinishReason: "stop", + }, + }, + } + + tools := newMockToolExecutor(map[string]string{ + "http_request": `{"status": 200, "body": "ok"}`, + }) + + executor := NewRuntime(RuntimeConfig{ + LLMClient: toolCallClient, + Tools: tools, + SystemPrompt: "You are a test agent with tools.", + MaxIterations: 10, + }) + + task := &a2a.Task{ID: "tool-task-1"} + msg := &a2a.Message{ + Role: a2a.MessageRoleUser, + Parts: []a2a.Part{a2a.NewTextPart("Fetch https://example.com")}, + } + + resp, err := executor.Execute(context.Background(), task, msg) + if err != nil { + t.Fatalf("Execute() error: %v", err) + } + if resp == nil { + t.Fatal("Execute() returned nil") + } + if resp.Parts[0].Text != "I fetched the URL and got: ok" { + t.Errorf("response text = %q, want 'I fetched the URL and got: ok'", resp.Parts[0].Text) + } + + // Should have made 2 LLM calls + if toolCallClient.callIdx != 2 { + t.Errorf("LLM was called %d times, want 2", toolCallClient.callIdx) + } +} + +func TestNewRuntime_WithHooks(t *testing.T) { + client := &mockLLMClient{ + response: &llm.ChatResponse{ + Message: llm.ChatMessage{ + Role: llm.RoleAssistant, + Content: "Done", + }, + FinishReason: "stop", + }, + } + + hooks := runtime.NewHookRegistry() + beforeCalled := false + afterCalled := false + + hooks.Register(runtime.BeforeLLMCall, func(ctx context.Context, hc *runtime.HookContext) error { + beforeCalled = true + return nil + }) + hooks.Register(runtime.AfterLLMCall, func(ctx context.Context, hc *runtime.HookContext) error { + afterCalled = true + return nil + }) + + executor := NewRuntime(RuntimeConfig{ + LLMClient: client, + Tools: newMockToolExecutor(nil), + Hooks: hooks, + SystemPrompt: "Test", + MaxIterations: 5, + }) + + task := &a2a.Task{ID: "hook-task"} + msg := &a2a.Message{ + Role: a2a.MessageRoleUser, + Parts: []a2a.Part{a2a.NewTextPart("Test hooks")}, + } + + _, err := executor.Execute(context.Background(), task, msg) + if err != nil { + t.Fatalf("Execute() error: %v", err) + } + + if !beforeCalled { + t.Error("BeforeLLMCall hook was not called") + } + if !afterCalled { + t.Error("AfterLLMCall hook was not called") + } +} + +func TestNewRuntime_ToolExecHooks(t *testing.T) { + toolCallClient := &sequentialMockClient{ + responses: []*llm.ChatResponse{ + { + Message: llm.ChatMessage{ + Role: llm.RoleAssistant, + ToolCalls: []llm.ToolCall{ + { + ID: "call-hook-1", + Type: "function", + Function: llm.FunctionCall{ + Name: "http_request", + Arguments: `{}`, + }, + }, + }, + }, + FinishReason: "tool_calls", + }, + { + Message: llm.ChatMessage{ + Role: llm.RoleAssistant, + Content: "Done with tool", + }, + FinishReason: "stop", + }, + }, + } + + hooks := runtime.NewHookRegistry() + beforeToolCalled := false + afterToolCalled := false + var capturedToolName string + + hooks.Register(runtime.BeforeToolExec, func(ctx context.Context, hc *runtime.HookContext) error { + beforeToolCalled = true + capturedToolName = hc.ToolName + return nil + }) + hooks.Register(runtime.AfterToolExec, func(ctx context.Context, hc *runtime.HookContext) error { + afterToolCalled = true + return nil + }) + + executor := NewRuntime(RuntimeConfig{ + LLMClient: toolCallClient, + Tools: newMockToolExecutor(map[string]string{"http_request": `{"ok":true}`}), + Hooks: hooks, + SystemPrompt: "Test", + MaxIterations: 5, + }) + + task := &a2a.Task{ID: "tool-hook-task"} + msg := &a2a.Message{ + Role: a2a.MessageRoleUser, + Parts: []a2a.Part{a2a.NewTextPart("Use a tool")}, + } + + _, err := executor.Execute(context.Background(), task, msg) + if err != nil { + t.Fatalf("Execute() error: %v", err) + } + + if !beforeToolCalled { + t.Error("BeforeToolExec hook was not called") + } + if !afterToolCalled { + t.Error("AfterToolExec hook was not called") + } + if capturedToolName != "http_request" { + t.Errorf("hook captured tool name = %q, want http_request", capturedToolName) + } +} + +func TestNewRuntime_MaxIterations(t *testing.T) { + // LLM always returns tool calls, never stops + client := &mockLLMClient{ + response: &llm.ChatResponse{ + Message: llm.ChatMessage{ + Role: llm.RoleAssistant, + ToolCalls: []llm.ToolCall{ + { + ID: "call-loop", + Type: "function", + Function: llm.FunctionCall{ + Name: "http_request", + Arguments: `{}`, + }, + }, + }, + }, + FinishReason: "tool_calls", + }, + } + + tools := newMockToolExecutor(map[string]string{ + "http_request": `{"ok": true}`, + }) + + executor := NewRuntime(RuntimeConfig{ + LLMClient: client, + Tools: tools, + MaxIterations: 3, + }) + + task := &a2a.Task{ID: "loop-task"} + msg := &a2a.Message{ + Role: a2a.MessageRoleUser, + Parts: []a2a.Part{a2a.NewTextPart("Loop forever")}, + } + + _, err := executor.Execute(context.Background(), task, msg) + if err == nil { + t.Fatal("expected error for max iterations exceeded") + } + + // Should have called LLM exactly maxIterations times + if client.calls != 3 { + t.Errorf("LLM was called %d times, want 3", client.calls) + } +} + +func TestNewRuntime_NilTools(t *testing.T) { + client := &mockLLMClient{ + response: &llm.ChatResponse{ + Message: llm.ChatMessage{ + Role: llm.RoleAssistant, + Content: "Response without tools", + }, + FinishReason: "stop", + }, + } + + executor := NewRuntime(RuntimeConfig{ + LLMClient: client, + Tools: nil, + SystemPrompt: "No tools", + MaxIterations: 5, + }) + + task := &a2a.Task{ID: "no-tools-task"} + msg := &a2a.Message{ + Role: a2a.MessageRoleUser, + Parts: []a2a.Part{a2a.NewTextPart("Hello")}, + } + + resp, err := executor.Execute(context.Background(), task, msg) + if err != nil { + t.Fatalf("Execute() error: %v", err) + } + if resp == nil { + t.Fatal("Execute() returned nil") + } + if resp.Parts[0].Text != "Response without tools" { + t.Errorf("unexpected response text: %q", resp.Parts[0].Text) + } +} + +func TestNewRuntime_LLMError(t *testing.T) { + client := &mockLLMClient{ + err: context.DeadlineExceeded, + } + + executor := NewRuntime(RuntimeConfig{ + LLMClient: client, + Tools: newMockToolExecutor(nil), + MaxIterations: 5, + }) + + task := &a2a.Task{ID: "error-task"} + msg := &a2a.Message{ + Role: a2a.MessageRoleUser, + Parts: []a2a.Part{a2a.NewTextPart("Will fail")}, + } + + _, err := executor.Execute(context.Background(), task, msg) + if err == nil { + t.Fatal("expected error from LLM client") + } +} + +func TestNewRuntime_DefaultMaxIterations(t *testing.T) { + // If MaxIterations is 0, should default to 10 + client := &mockLLMClient{ + response: &llm.ChatResponse{ + Message: llm.ChatMessage{ + Role: llm.RoleAssistant, + Content: "ok", + }, + FinishReason: "stop", + }, + } + + executor := NewRuntime(RuntimeConfig{ + LLMClient: client, + // MaxIterations: 0 -> defaults to 10 + }) + + if executor == nil { + t.Fatal("NewRuntime() returned nil") + } + + task := &a2a.Task{ID: "default-iter-task"} + msg := &a2a.Message{ + Role: a2a.MessageRoleUser, + Parts: []a2a.Part{a2a.NewTextPart("Hello")}, + } + + resp, err := executor.Execute(context.Background(), task, msg) + if err != nil { + t.Fatalf("Execute() error: %v", err) + } + if resp == nil { + t.Fatal("Execute() returned nil") + } +} + +func TestNewRuntime_TaskHistory(t *testing.T) { + client := &mockLLMClient{ + response: &llm.ChatResponse{ + Message: llm.ChatMessage{ + Role: llm.RoleAssistant, + Content: "I remember our conversation", + }, + FinishReason: "stop", + }, + } + + executor := NewRuntime(RuntimeConfig{ + LLMClient: client, + Tools: newMockToolExecutor(nil), + SystemPrompt: "You are helpful", + MaxIterations: 5, + }) + + task := &a2a.Task{ + ID: "history-task", + History: []a2a.Message{ + {Role: a2a.MessageRoleUser, Parts: []a2a.Part{a2a.NewTextPart("Previous message")}}, + {Role: a2a.MessageRoleAgent, Parts: []a2a.Part{a2a.NewTextPart("Previous response")}}, + }, + } + msg := &a2a.Message{ + Role: a2a.MessageRoleUser, + Parts: []a2a.Part{a2a.NewTextPart("New message")}, + } + + resp, err := executor.Execute(context.Background(), task, msg) + if err != nil { + t.Fatalf("Execute() error: %v", err) + } + if resp == nil { + t.Fatal("Execute() returned nil") + } +} + +// ─── Integration: Compile -> Validate -> Runtime ───────────────────── + +func TestIntegration_CompileValidateRuntime(t *testing.T) { + // 1. Define config in memory (no disk) + cfg := &types.ForgeConfig{ + AgentID: "integration-agent", + Version: "1.0.0", + Framework: "custom", + Entrypoint: "python main.py", + Model: types.ModelRef{ + Provider: "openai", + Name: "gpt-4", + }, + Tools: []types.ToolRef{ + {Name: "http_request", Type: "builtin"}, + }, + Egress: types.EgressRef{ + Profile: "strict", + Mode: "allowlist", + AllowedDomains: []string{"api.openai.com"}, + }, + } + + // 2. Validate config + valResult := ValidateConfig(cfg) + if !valResult.IsValid() { + t.Fatalf("ValidateConfig() failed: %v", valResult.Errors) + } + + // 3. Compile + compileResult, err := Compile(CompileRequest{ + Config: cfg, + SkillEntries: []skills.SkillEntry{ + {Name: "summarize", Description: "Summarize content"}, + }, + }) + if err != nil { + t.Fatalf("Compile() error: %v", err) + } + + // Verify compilation output + if compileResult.Spec == nil { + t.Fatal("Compile() returned nil Spec") + } + if compileResult.CompiledSkills == nil { + t.Fatal("Compile() returned nil CompiledSkills") + } + if compileResult.CompiledSkills.Count != 1 { + t.Errorf("CompiledSkills.Count = %d, want 1", compileResult.CompiledSkills.Count) + } + + // 4. Validate spec for Command compatibility + spec := compileResult.Spec + // spec already has ForgeVersion, Runtime, etc from compilation + + compatResult := ValidateCommandCompat(spec) + if !compatResult.IsValid() { + t.Fatalf("ValidateCommandCompat() failed: %v", compatResult.Errors) + } + + // 5. Simulate import + importSim := SimulateImport(spec) + if importSim.Definition.Slug != "integration-agent" { + t.Errorf("import sim slug = %q, want integration-agent", importSim.Definition.Slug) + } + if importSim.Definition.ContainerImage != "python:3.12-slim" { + t.Errorf("import sim image = %q, want python:3.12-slim", importSim.Definition.ContainerImage) + } + if importSim.Definition.ModelProvider != "openai" { + t.Errorf("import sim model provider = %q, want openai", importSim.Definition.ModelProvider) + } + + // 6. Create runtime with injected mock LLM + mockClient := &mockLLMClient{ + response: &llm.ChatResponse{ + Message: llm.ChatMessage{ + Role: llm.RoleAssistant, + Content: "Integration test response", + }, + FinishReason: "stop", + }, + } + + executor := NewRuntime(RuntimeConfig{ + LLMClient: mockClient, + Tools: newMockToolExecutor(map[string]string{"http_request": `{"ok":true}`}), + SystemPrompt: "You are " + cfg.AgentID, + MaxIterations: 5, + }) + + // 7. Execute agent + task := &a2a.Task{ID: "integration-1"} + msg := &a2a.Message{ + Role: a2a.MessageRoleUser, + Parts: []a2a.Part{a2a.NewTextPart("Hello from integration test")}, + } + + resp, err := executor.Execute(context.Background(), task, msg) + if err != nil { + t.Fatalf("Execute() error: %v", err) + } + if resp == nil || len(resp.Parts) == 0 { + t.Fatal("Execute() returned empty response") + } + if resp.Parts[0].Text != "Integration test response" { + t.Errorf("response text = %q, want 'Integration test response'", resp.Parts[0].Text) + } + if mockClient.calls != 1 { + t.Errorf("LLM was called %d times, want 1", mockClient.calls) + } +} + +func TestIntegration_CompileWithToolCallLoop(t *testing.T) { + // Full integration: compile config, then run through tool-calling loop + cfg := &types.ForgeConfig{ + AgentID: "tool-loop-agent", + Version: "1.0.0", + Framework: "custom", + Entrypoint: "python main.py", + Tools: []types.ToolRef{ + {Name: "web_search", Type: "builtin"}, + {Name: "http_request", Type: "builtin"}, + }, + Egress: types.EgressRef{ + Profile: "standard", + Mode: "allowlist", + AllowedDomains: []string{"api.google.com", "example.com"}, + }, + } + + // Validate + valResult := ValidateConfig(cfg) + if !valResult.IsValid() { + t.Fatalf("ValidateConfig() failed: %v", valResult.Errors) + } + + // Compile + compileResult, err := Compile(CompileRequest{Config: cfg}) + if err != nil { + t.Fatalf("Compile() error: %v", err) + } + if len(compileResult.Spec.Tools) != 2 { + t.Errorf("got %d tools in spec, want 2", len(compileResult.Spec.Tools)) + } + + // Create runtime with multi-step tool calling + toolCallClient := &sequentialMockClient{ + responses: []*llm.ChatResponse{ + { + Message: llm.ChatMessage{ + Role: llm.RoleAssistant, + ToolCalls: []llm.ToolCall{ + { + ID: "call-search", + Type: "function", + Function: llm.FunctionCall{ + Name: "web_search", + Arguments: `{"query": "test"}`, + }, + }, + }, + }, + FinishReason: "tool_calls", + }, + { + Message: llm.ChatMessage{ + Role: llm.RoleAssistant, + ToolCalls: []llm.ToolCall{ + { + ID: "call-fetch", + Type: "function", + Function: llm.FunctionCall{ + Name: "http_request", + Arguments: `{"url": "https://example.com/result"}`, + }, + }, + }, + }, + FinishReason: "tool_calls", + }, + { + Message: llm.ChatMessage{ + Role: llm.RoleAssistant, + Content: "Found and fetched the result", + }, + FinishReason: "stop", + }, + }, + } + + executor := NewRuntime(RuntimeConfig{ + LLMClient: toolCallClient, + Tools: newMockToolExecutor(map[string]string{ + "web_search": `{"results": ["https://example.com/result"]}`, + "http_request": `{"status": 200, "body": "result data"}`, + }), + SystemPrompt: "You are " + cfg.AgentID, + MaxIterations: 10, + }) + + task := &a2a.Task{ID: "multi-tool-task"} + msg := &a2a.Message{ + Role: a2a.MessageRoleUser, + Parts: []a2a.Part{a2a.NewTextPart("Search and fetch results")}, + } + + resp, err := executor.Execute(context.Background(), task, msg) + if err != nil { + t.Fatalf("Execute() error: %v", err) + } + if resp == nil || len(resp.Parts) == 0 { + t.Fatal("Execute() returned empty response") + } + if resp.Parts[0].Text != "Found and fetched the result" { + t.Errorf("response text = %q", resp.Parts[0].Text) + } + if toolCallClient.callIdx != 3 { + t.Errorf("LLM was called %d times, want 3", toolCallClient.callIdx) + } +} + +func TestIntegration_EgressAllowlistJSON(t *testing.T) { + cfg := &types.ForgeConfig{ + AgentID: "egress-test", + Version: "1.0.0", + Framework: "custom", + Entrypoint: "python main.py", + Egress: types.EgressRef{ + Profile: "strict", + Mode: "allowlist", + AllowedDomains: []string{"api.openai.com", "hooks.slack.com"}, + }, + } + + result, err := Compile(CompileRequest{Config: cfg}) + if err != nil { + t.Fatalf("Compile() error: %v", err) + } + + // Verify allowlist JSON can be parsed + var allowlist map[string]interface{} + if err := json.Unmarshal(result.Allowlist, &allowlist); err != nil { + t.Fatalf("failed to parse Allowlist JSON: %v", err) + } + + if allowlist["profile"] != "strict" { + t.Errorf("allowlist profile = %v, want strict", allowlist["profile"]) + } + if allowlist["mode"] != "allowlist" { + t.Errorf("allowlist mode = %v, want allowlist", allowlist["mode"]) + } + + // Verify allowed_domains is present + domains, ok := allowlist["allowed_domains"].([]interface{}) + if !ok { + t.Fatal("allowed_domains is not an array") + } + if len(domains) != 2 { + t.Errorf("got %d allowed_domains, want 2", len(domains)) + } +} diff --git a/forge-core/go.mod b/forge-core/go.mod new file mode 100644 index 0000000..18e3efd --- /dev/null +++ b/forge-core/go.mod @@ -0,0 +1,13 @@ +module github.com/initializ/forge/forge-core + +go 1.25.0 + +require ( + github.com/xeipuuv/gojsonschema v1.2.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect +) diff --git a/forge-core/go.sum b/forge-core/go.sum new file mode 100644 index 0000000..d1ac988 --- /dev/null +++ b/forge-core/go.sum @@ -0,0 +1,36 @@ +github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= +github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b h1:MQE+LT/ABUuuvEZ+YQAMSXindAdUh7slEmAkup74op4= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/forge-core/llm/client.go b/forge-core/llm/client.go new file mode 100644 index 0000000..71e83f5 --- /dev/null +++ b/forge-core/llm/client.go @@ -0,0 +1,23 @@ +package llm + +import "context" + +// Client is the interface for interacting with an LLM provider. +type Client interface { + // Chat sends a chat completion request and returns the response. + Chat(ctx context.Context, req *ChatRequest) (*ChatResponse, error) + // ChatStream sends a streaming chat request and returns a channel of deltas. + ChatStream(ctx context.Context, req *ChatRequest) (<-chan StreamDelta, error) + // ModelID returns the model identifier this client is configured for. + ModelID() string +} + +// ClientConfig holds configuration for creating an LLM client. +type ClientConfig struct { + APIKey string + BaseURL string + Model string + OrgID string + MaxRetries int + TimeoutSecs int +} diff --git a/forge-core/llm/providers/anthropic.go b/forge-core/llm/providers/anthropic.go new file mode 100644 index 0000000..28f20ef --- /dev/null +++ b/forge-core/llm/providers/anthropic.go @@ -0,0 +1,381 @@ +package providers + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/initializ/forge/forge-core/llm" +) + +// AnthropicClient implements llm.Client for the Anthropic Messages API. +type AnthropicClient struct { + apiKey string + baseURL string + model string + client *http.Client +} + +// NewAnthropicClient creates a new Anthropic client. +func NewAnthropicClient(cfg llm.ClientConfig) *AnthropicClient { + baseURL := cfg.BaseURL + if baseURL == "" { + baseURL = "https://api.anthropic.com" + } + timeout := time.Duration(cfg.TimeoutSecs) * time.Second + if timeout == 0 { + timeout = 120 * time.Second + } + return &AnthropicClient{ + apiKey: cfg.APIKey, + baseURL: strings.TrimRight(baseURL, "/"), + model: cfg.Model, + client: &http.Client{Timeout: timeout}, + } +} + +func (c *AnthropicClient) ModelID() string { return c.model } + +// Chat sends a non-streaming messages request. +func (c *AnthropicClient) Chat(ctx context.Context, req *llm.ChatRequest) (*llm.ChatResponse, error) { + body := c.toAnthropicRequest(req, false) + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshalling request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/messages", bytes.NewReader(data)) + if err != nil { + return nil, err + } + c.setHeaders(httpReq) + + resp, err := c.client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("anthropic request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("anthropic error (status %d): %s", resp.StatusCode, string(respBody)) + } + + return c.parseAnthropicResponse(resp.Body) +} + +// ChatStream sends a streaming messages request. +func (c *AnthropicClient) ChatStream(ctx context.Context, req *llm.ChatRequest) (<-chan llm.StreamDelta, error) { + body := c.toAnthropicRequest(req, true) + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshalling request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/messages", bytes.NewReader(data)) + if err != nil { + return nil, err + } + c.setHeaders(httpReq) + + resp, err := c.client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("anthropic stream request: %w", err) + } + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + return nil, fmt.Errorf("anthropic stream error (status %d): %s", resp.StatusCode, string(respBody)) + } + + ch := make(chan llm.StreamDelta, 32) + go func() { + defer func() { _ = resp.Body.Close() }() + defer close(ch) + c.readAnthropicStream(resp.Body, ch) + }() + + return ch, nil +} + +func (c *AnthropicClient) setHeaders(req *http.Request) { + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", c.apiKey) + req.Header.Set("anthropic-version", "2023-06-01") +} + +// Anthropic-specific request types. +type anthropicRequest struct { + Model string `json:"model"` + Messages []anthropicMessage `json:"messages"` + System string `json:"system,omitempty"` + MaxTokens int `json:"max_tokens"` + Tools []anthropicTool `json:"tools,omitempty"` + Stream bool `json:"stream,omitempty"` +} + +type anthropicMessage struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` +} + +type anthropicContentBlock struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Input json.RawMessage `json:"input,omitempty"` + ToolUseID string `json:"tool_use_id,omitempty"` + Content string `json:"content,omitempty"` +} + +type anthropicTool struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema json.RawMessage `json:"input_schema"` +} + +func (c *AnthropicClient) toAnthropicRequest(req *llm.ChatRequest, stream bool) anthropicRequest { + model := req.Model + if model == "" { + model = c.model + } + + maxTokens := req.MaxTokens + if maxTokens == 0 { + maxTokens = 4096 + } + + r := anthropicRequest{ + Model: model, + MaxTokens: maxTokens, + Stream: stream, + } + + // Extract system message and convert remaining messages + for _, m := range req.Messages { + if m.Role == llm.RoleSystem { + r.System = m.Content + continue + } + r.Messages = append(r.Messages, c.convertMessage(m)) + } + + // Convert tools + for _, t := range req.Tools { + r.Tools = append(r.Tools, anthropicTool{ + Name: t.Function.Name, + Description: t.Function.Description, + InputSchema: t.Function.Parameters, + }) + } + + return r +} + +func (c *AnthropicClient) convertMessage(m llm.ChatMessage) anthropicMessage { + role := m.Role + if role == llm.RoleAssistant { + role = "assistant" + } + + // Tool result message + if m.Role == llm.RoleTool { + blocks := []anthropicContentBlock{ + { + Type: "tool_result", + ToolUseID: m.ToolCallID, + Content: m.Content, + }, + } + data, _ := json.Marshal(blocks) + return anthropicMessage{Role: "user", Content: data} + } + + // Assistant message with tool calls + if m.Role == llm.RoleAssistant && len(m.ToolCalls) > 0 { + var blocks []anthropicContentBlock + if m.Content != "" { + blocks = append(blocks, anthropicContentBlock{Type: "text", Text: m.Content}) + } + for _, tc := range m.ToolCalls { + blocks = append(blocks, anthropicContentBlock{ + Type: "tool_use", + ID: tc.ID, + Name: tc.Function.Name, + Input: json.RawMessage(tc.Function.Arguments), + }) + } + data, _ := json.Marshal(blocks) + return anthropicMessage{Role: "assistant", Content: data} + } + + // Simple text message + data, _ := json.Marshal(m.Content) + return anthropicMessage{Role: role, Content: data} +} + +// Anthropic-specific response types. +type anthropicResponse struct { + ID string `json:"id"` + Content []anthropicContentBlock `json:"content"` + StopReason string `json:"stop_reason"` + Usage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + } `json:"usage"` +} + +func (c *AnthropicClient) parseAnthropicResponse(body io.Reader) (*llm.ChatResponse, error) { + var resp anthropicResponse + if err := json.NewDecoder(body).Decode(&resp); err != nil { + return nil, fmt.Errorf("decoding anthropic response: %w", err) + } + + msg := llm.ChatMessage{Role: llm.RoleAssistant} + for _, block := range resp.Content { + switch block.Type { + case "text": + msg.Content += block.Text + case "tool_use": + msg.ToolCalls = append(msg.ToolCalls, llm.ToolCall{ + ID: block.ID, + Type: "function", + Function: llm.FunctionCall{ + Name: block.Name, + Arguments: string(block.Input), + }, + }) + } + } + + finishReason := "stop" + if resp.StopReason == "tool_use" { + finishReason = "tool_calls" + } else if resp.StopReason == "end_turn" { + finishReason = "stop" + } else if resp.StopReason != "" { + finishReason = resp.StopReason + } + + return &llm.ChatResponse{ + ID: resp.ID, + Message: msg, + Usage: llm.UsageInfo{ + PromptTokens: resp.Usage.InputTokens, + CompletionTokens: resp.Usage.OutputTokens, + TotalTokens: resp.Usage.InputTokens + resp.Usage.OutputTokens, + }, + FinishReason: finishReason, + }, nil +} + +// Anthropic streaming event types. +type anthropicContentBlockStart struct { + Index int `json:"index"` + ContentBlock anthropicContentBlock `json:"content_block"` +} + +type anthropicContentBlockDelta struct { + Index int `json:"index"` + Delta struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + PartialJSON string `json:"partial_json,omitempty"` + } `json:"delta"` +} + +type anthropicMessageDelta struct { + Delta struct { + StopReason string `json:"stop_reason"` + } `json:"delta"` + Usage struct { + OutputTokens int `json:"output_tokens"` + } `json:"usage"` +} + +func (c *AnthropicClient) readAnthropicStream(r io.Reader, ch chan<- llm.StreamDelta) { + scanner := bufio.NewScanner(r) + var currentToolCall *llm.ToolCall + var eventType string + + for scanner.Scan() { + line := scanner.Text() + + if after, ok := strings.CutPrefix(line, "event: "); ok { + eventType = after + continue + } + + after, ok := strings.CutPrefix(line, "data: ") + if !ok { + continue + } + + switch eventType { + case "content_block_start": + var ev anthropicContentBlockStart + if json.Unmarshal([]byte(after), &ev) != nil { + continue + } + if ev.ContentBlock.Type == "tool_use" { + currentToolCall = &llm.ToolCall{ + ID: ev.ContentBlock.ID, + Type: "function", + Function: llm.FunctionCall{ + Name: ev.ContentBlock.Name, + }, + } + } + + case "content_block_delta": + var ev anthropicContentBlockDelta + if json.Unmarshal([]byte(after), &ev) != nil { + continue + } + switch ev.Delta.Type { + case "text_delta": + ch <- llm.StreamDelta{Content: ev.Delta.Text} + case "input_json_delta": + if currentToolCall != nil { + currentToolCall.Function.Arguments += ev.Delta.PartialJSON + } + } + + case "content_block_stop": + if currentToolCall != nil { + ch <- llm.StreamDelta{ + ToolCalls: []llm.ToolCall{*currentToolCall}, + } + currentToolCall = nil + } + + case "message_delta": + var ev anthropicMessageDelta + if json.Unmarshal([]byte(after), &ev) != nil { + continue + } + finishReason := "stop" + if ev.Delta.StopReason == "tool_use" { + finishReason = "tool_calls" + } + ch <- llm.StreamDelta{ + FinishReason: finishReason, + Usage: &llm.UsageInfo{ + CompletionTokens: ev.Usage.OutputTokens, + }, + } + + case "message_stop": + ch <- llm.StreamDelta{Done: true} + return + } + } +} diff --git a/forge-core/llm/providers/factory.go b/forge-core/llm/providers/factory.go new file mode 100644 index 0000000..5c54e50 --- /dev/null +++ b/forge-core/llm/providers/factory.go @@ -0,0 +1,27 @@ +package providers + +import ( + "fmt" + + "github.com/initializ/forge/forge-core/llm" +) + +// NewClient creates an LLM client for the specified provider. +// Supported providers: "openai", "anthropic", "ollama". +func NewClient(provider string, cfg llm.ClientConfig) (llm.Client, error) { + switch provider { + case "openai": + return NewOpenAIClient(cfg), nil + case "anthropic": + return NewAnthropicClient(cfg), nil + case "gemini": + if cfg.BaseURL == "" { + cfg.BaseURL = "https://generativelanguage.googleapis.com/v1beta/openai" + } + return NewOpenAIClient(cfg), nil + case "ollama": + return NewOllamaClient(cfg), nil + default: + return nil, fmt.Errorf("unknown LLM provider: %q", provider) + } +} diff --git a/forge-core/llm/providers/ollama.go b/forge-core/llm/providers/ollama.go new file mode 100644 index 0000000..c77e63f --- /dev/null +++ b/forge-core/llm/providers/ollama.go @@ -0,0 +1,22 @@ +package providers + +import "github.com/initializ/forge/forge-core/llm" + +// OllamaClient wraps OpenAIClient with Ollama-specific defaults. +// Ollama provides an OpenAI-compatible API at localhost:11434/v1. +type OllamaClient struct { + *OpenAIClient +} + +// NewOllamaClient creates a client that talks to a local Ollama server. +func NewOllamaClient(cfg llm.ClientConfig) *OllamaClient { + if cfg.BaseURL == "" { + cfg.BaseURL = "http://localhost:11434/v1" + } + if cfg.APIKey == "" { + cfg.APIKey = "ollama" // Ollama requires a non-empty key + } + return &OllamaClient{ + OpenAIClient: NewOpenAIClient(cfg), + } +} diff --git a/forge-core/llm/providers/openai.go b/forge-core/llm/providers/openai.go new file mode 100644 index 0000000..bf54f51 --- /dev/null +++ b/forge-core/llm/providers/openai.go @@ -0,0 +1,275 @@ +// Package providers implements LLM client providers for various APIs. +package providers + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/initializ/forge/forge-core/llm" +) + +// OpenAIClient implements llm.Client for the OpenAI Chat Completions API. +// Also works with Azure OpenAI and any OpenAI-compatible endpoint. +type OpenAIClient struct { + apiKey string + baseURL string + model string + orgID string + client *http.Client +} + +// NewOpenAIClient creates a new OpenAI client. +func NewOpenAIClient(cfg llm.ClientConfig) *OpenAIClient { + baseURL := cfg.BaseURL + if baseURL == "" { + baseURL = "https://api.openai.com/v1" + } + timeout := time.Duration(cfg.TimeoutSecs) * time.Second + if timeout == 0 { + timeout = 120 * time.Second + } + return &OpenAIClient{ + apiKey: cfg.APIKey, + baseURL: strings.TrimRight(baseURL, "/"), + model: cfg.Model, + orgID: cfg.OrgID, + client: &http.Client{Timeout: timeout}, + } +} + +func (c *OpenAIClient) ModelID() string { return c.model } + +// Chat sends a non-streaming chat completion request. +func (c *OpenAIClient) Chat(ctx context.Context, req *llm.ChatRequest) (*llm.ChatResponse, error) { + body := c.toOpenAIRequest(req, false) + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshalling request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/chat/completions", bytes.NewReader(data)) + if err != nil { + return nil, err + } + c.setHeaders(httpReq) + + resp, err := c.client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("openai request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("openai error (status %d): %s", resp.StatusCode, string(respBody)) + } + + return c.parseOpenAIResponse(resp.Body) +} + +// ChatStream sends a streaming chat completion request. +func (c *OpenAIClient) ChatStream(ctx context.Context, req *llm.ChatRequest) (<-chan llm.StreamDelta, error) { + body := c.toOpenAIRequest(req, true) + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshalling request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/chat/completions", bytes.NewReader(data)) + if err != nil { + return nil, err + } + c.setHeaders(httpReq) + + resp, err := c.client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("openai stream request: %w", err) + } + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + return nil, fmt.Errorf("openai stream error (status %d): %s", resp.StatusCode, string(respBody)) + } + + ch := make(chan llm.StreamDelta, 32) + go func() { + defer func() { _ = resp.Body.Close() }() + defer close(ch) + c.readSSEStream(resp.Body, ch) + }() + + return ch, nil +} + +func (c *OpenAIClient) setHeaders(req *http.Request) { + req.Header.Set("Content-Type", "application/json") + if c.apiKey != "" { + req.Header.Set("Authorization", "Bearer "+c.apiKey) + } + if c.orgID != "" { + req.Header.Set("OpenAI-Organization", c.orgID) + } +} + +// openaiRequest is the OpenAI-specific request format. +type openaiRequest struct { + Model string `json:"model"` + Messages []openaiMessage `json:"messages"` + Tools []llm.ToolDefinition `json:"tools,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + Stream bool `json:"stream,omitempty"` + StreamOptions *streamOptions `json:"stream_options,omitempty"` +} + +type streamOptions struct { + IncludeUsage bool `json:"include_usage"` +} + +type openaiMessage struct { + Role string `json:"role"` + Content string `json:"content,omitempty"` + ToolCalls []llm.ToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + Name string `json:"name,omitempty"` +} + +func (c *OpenAIClient) toOpenAIRequest(req *llm.ChatRequest, stream bool) openaiRequest { + model := req.Model + if model == "" { + model = c.model + } + + msgs := make([]openaiMessage, len(req.Messages)) + for i, m := range req.Messages { + msgs[i] = openaiMessage{ + Role: m.Role, + Content: m.Content, + ToolCalls: m.ToolCalls, + ToolCallID: m.ToolCallID, + Name: m.Name, + } + } + + r := openaiRequest{ + Model: model, + Messages: msgs, + Tools: req.Tools, + Temperature: req.Temperature, + MaxTokens: req.MaxTokens, + Stream: stream, + } + + if stream { + r.StreamOptions = &streamOptions{IncludeUsage: true} + } + + return r +} + +// openaiResponse is the OpenAI-specific response format. +type openaiResponse struct { + ID string `json:"id"` + Choices []struct { + Message struct { + Role string `json:"role"` + Content string `json:"content"` + ToolCalls []llm.ToolCall `json:"tool_calls,omitempty"` + } `json:"message"` + FinishReason string `json:"finish_reason"` + } `json:"choices"` + Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + } `json:"usage"` +} + +func (c *OpenAIClient) parseOpenAIResponse(body io.Reader) (*llm.ChatResponse, error) { + var resp openaiResponse + if err := json.NewDecoder(body).Decode(&resp); err != nil { + return nil, fmt.Errorf("decoding openai response: %w", err) + } + + if len(resp.Choices) == 0 { + return nil, fmt.Errorf("openai returned no choices") + } + + choice := resp.Choices[0] + return &llm.ChatResponse{ + ID: resp.ID, + Message: llm.ChatMessage{ + Role: choice.Message.Role, + Content: choice.Message.Content, + ToolCalls: choice.Message.ToolCalls, + }, + Usage: llm.UsageInfo{ + PromptTokens: resp.Usage.PromptTokens, + CompletionTokens: resp.Usage.CompletionTokens, + TotalTokens: resp.Usage.TotalTokens, + }, + FinishReason: choice.FinishReason, + }, nil +} + +// openaiStreamChunk is a streaming response chunk. +type openaiStreamChunk struct { + Choices []struct { + Delta struct { + Content string `json:"content"` + ToolCalls []llm.ToolCall `json:"tool_calls,omitempty"` + } `json:"delta"` + FinishReason *string `json:"finish_reason"` + } `json:"choices"` + Usage *struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + } `json:"usage,omitempty"` +} + +func (c *OpenAIClient) readSSEStream(r io.Reader, ch chan<- llm.StreamDelta) { + scanner := bufio.NewScanner(r) + for scanner.Scan() { + line := scanner.Text() + if line == "data: [DONE]" { + ch <- llm.StreamDelta{Done: true} + return + } + after, ok := strings.CutPrefix(line, "data: ") + if !ok { + continue + } + + var chunk openaiStreamChunk + if err := json.Unmarshal([]byte(after), &chunk); err != nil { + continue + } + + delta := llm.StreamDelta{} + if len(chunk.Choices) > 0 { + c0 := chunk.Choices[0] + delta.Content = c0.Delta.Content + delta.ToolCalls = c0.Delta.ToolCalls + if c0.FinishReason != nil { + delta.FinishReason = *c0.FinishReason + } + } + if chunk.Usage != nil { + delta.Usage = &llm.UsageInfo{ + PromptTokens: chunk.Usage.PromptTokens, + CompletionTokens: chunk.Usage.CompletionTokens, + TotalTokens: chunk.Usage.TotalTokens, + } + } + ch <- delta + } +} diff --git a/forge-core/llm/types.go b/forge-core/llm/types.go new file mode 100644 index 0000000..faa9953 --- /dev/null +++ b/forge-core/llm/types.go @@ -0,0 +1,83 @@ +// Package llm provides canonical types for LLM chat interactions. +// These types are provider-agnostic; each provider translates to/from +// its native API format. +package llm + +import "encoding/json" + +// Role constants for chat messages. +const ( + RoleSystem = "system" + RoleUser = "user" + RoleAssistant = "assistant" + RoleTool = "tool" +) + +// ChatMessage represents a single message in a chat conversation. +type ChatMessage struct { + Role string `json:"role"` + Content string `json:"content,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + Name string `json:"name,omitempty"` +} + +// ToolCall represents an LLM request to invoke a tool. +type ToolCall struct { + ID string `json:"id"` + Type string `json:"type"` // always "function" + Function FunctionCall `json:"function"` +} + +// FunctionCall contains the function name and arguments for a tool call. +type FunctionCall struct { + Name string `json:"name"` + Arguments string `json:"arguments"` // JSON string +} + +// ToolDefinition describes a tool available to the LLM. +type ToolDefinition struct { + Type string `json:"type"` // always "function" + Function FunctionSchema `json:"function"` +} + +// FunctionSchema describes a function's name, description, and parameters. +type FunctionSchema struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters json.RawMessage `json:"parameters,omitempty"` +} + +// ChatRequest is a provider-agnostic chat completion request. +type ChatRequest struct { + Model string `json:"model"` + Messages []ChatMessage `json:"messages"` + Tools []ToolDefinition `json:"tools,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + Stream bool `json:"stream,omitempty"` +} + +// ChatResponse is a provider-agnostic chat completion response. +type ChatResponse struct { + ID string `json:"id"` + Message ChatMessage `json:"message"` + Usage UsageInfo `json:"usage"` + FinishReason string `json:"finish_reason"` +} + +// StreamDelta represents a single chunk in a streaming response. +type StreamDelta struct { + Content string `json:"content,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + FinishReason string `json:"finish_reason,omitempty"` + Done bool `json:"done,omitempty"` + Usage *UsageInfo `json:"usage,omitempty"` +} + +// UsageInfo contains token usage information. +type UsageInfo struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` +} diff --git a/forge-core/pipeline/context.go b/forge-core/pipeline/context.go new file mode 100644 index 0000000..42a6b8d --- /dev/null +++ b/forge-core/pipeline/context.go @@ -0,0 +1,45 @@ +package pipeline + +import ( + "github.com/initializ/forge/forge-core/agentspec" + "github.com/initializ/forge/forge-core/plugins" + "github.com/initializ/forge/forge-core/types" +) + +// BuildContext carries all state through the build pipeline. +type BuildContext struct { + Opts PipelineOptions + Config *types.ForgeConfig + Spec *agentspec.AgentSpec + GeneratedFiles map[string]string // relPath -> absPath + Warnings []string + Verbose bool + PluginConfig *plugins.AgentConfig // Set by FrameworkAdapterStage + WrapperFile string // Relative path to generated wrapper (empty = no wrapper) + + // Container packaging extensions + DevMode bool + ProdMode bool + EgressResolved any // *egress.EgressConfig (avoid import cycle) + SkillRequirements any // *skills.AggregatedRequirements (avoid import cycle) + SkillsCount int + ToolCategoryCounts map[string]int +} + +// NewBuildContext creates a BuildContext with the given options and initialized maps. +func NewBuildContext(opts PipelineOptions) *BuildContext { + return &BuildContext{ + Opts: opts, + GeneratedFiles: make(map[string]string), + } +} + +// AddFile records a generated file in the build context. +func (bc *BuildContext) AddFile(relPath, absPath string) { + bc.GeneratedFiles[relPath] = absPath +} + +// AddWarning appends a warning message to the build context. +func (bc *BuildContext) AddWarning(msg string) { + bc.Warnings = append(bc.Warnings, msg) +} diff --git a/forge-core/pipeline/pipeline.go b/forge-core/pipeline/pipeline.go new file mode 100644 index 0000000..c2b2f44 --- /dev/null +++ b/forge-core/pipeline/pipeline.go @@ -0,0 +1,44 @@ +// Package pipeline provides a sequential stage-based execution pipeline. +package pipeline + +import ( + "context" + "fmt" +) + +// Stage is a single unit of work in a build pipeline. +type Stage interface { + Name() string + Execute(ctx context.Context, bc *BuildContext) error +} + +// PipelineOptions carries shared configuration for all pipeline stages. +type PipelineOptions struct { + WorkDir string + OutputDir string + ConfigPath string + Env map[string]string +} + +// Pipeline executes a sequence of stages in order. +type Pipeline struct { + stages []Stage +} + +// New creates a Pipeline from the given stages. +func New(stages ...Stage) *Pipeline { + return &Pipeline{stages: stages} +} + +// Run executes each stage sequentially. It stops on the first error. +func (p *Pipeline) Run(ctx context.Context, bc *BuildContext) error { + for _, s := range p.stages { + if err := ctx.Err(); err != nil { + return fmt.Errorf("pipeline cancelled before stage %s: %w", s.Name(), err) + } + if err := s.Execute(ctx, bc); err != nil { + return fmt.Errorf("stage %s: %w", s.Name(), err) + } + } + return nil +} diff --git a/forge-core/plugins/plugin.go b/forge-core/plugins/plugin.go new file mode 100644 index 0000000..8a9f6c3 --- /dev/null +++ b/forge-core/plugins/plugin.go @@ -0,0 +1,102 @@ +// Package plugins provides a plugin registry and hook system for Forge. +package plugins + +import ( + "context" + "fmt" + "sync" +) + +// HookPoint identifies when a plugin hook fires. +type HookPoint string + +const ( + HookPreBuild HookPoint = "pre-build" + HookPostBuild HookPoint = "post-build" + HookPrePush HookPoint = "pre-push" + HookPostPush HookPoint = "post-push" +) + +// Plugin is the interface that Forge plugins must implement. +type Plugin interface { + // Name returns the unique plugin name. + Name() string + // Version returns the plugin version. + Version() string + // Init initialises the plugin with arbitrary configuration. + Init(config map[string]any) error + // Hooks returns the set of hook points this plugin wants to intercept. + Hooks() []HookPoint + // Execute runs the plugin logic for the given hook point. + Execute(ctx context.Context, hook HookPoint, data map[string]any) error +} + +// Registry stores registered plugins and provides lookup. +type Registry struct { + mu sync.RWMutex + plugins map[string]Plugin +} + +// NewRegistry creates an empty plugin registry. +func NewRegistry() *Registry { + return &Registry{plugins: make(map[string]Plugin)} +} + +// Register adds a plugin to the registry. It returns an error if a plugin +// with the same name is already registered. +func (r *Registry) Register(p Plugin) error { + r.mu.Lock() + defer r.mu.Unlock() + if _, exists := r.plugins[p.Name()]; exists { + return fmt.Errorf("plugin %q already registered", p.Name()) + } + r.plugins[p.Name()] = p + return nil +} + +// Get returns a plugin by name, or nil if not found. +func (r *Registry) Get(name string) Plugin { + r.mu.RLock() + defer r.mu.RUnlock() + return r.plugins[name] +} + +// ToolDefinition describes a tool discovered by a framework plugin. +type ToolDefinition struct { + Name string + Description string + InputSchema map[string]any +} + +// IdentityConfig holds agent identity metadata extracted by a plugin. +type IdentityConfig struct { + Role string + Goal string + Backstory string +} + +// PluginModelConfig holds model info extracted by a plugin. +type PluginModelConfig struct { + Provider string + Name string + Version string +} + +// AgentConfig is the intermediate representation from a FrameworkPlugin. +type AgentConfig struct { + Name string + Description string + Tools []ToolDefinition + Identity *IdentityConfig + Model *PluginModelConfig + Extra map[string]any +} + +// FrameworkPlugin adapts a specific agent framework to the Forge build pipeline. +type FrameworkPlugin interface { + Name() string + DetectProject(dir string) (bool, error) + ExtractAgentConfig(dir string) (*AgentConfig, error) + GenerateWrapper(config *AgentConfig) ([]byte, error) + RuntimeDependencies() []string +} diff --git a/forge-core/plugins/registry.go b/forge-core/plugins/registry.go new file mode 100644 index 0000000..8cec498 --- /dev/null +++ b/forge-core/plugins/registry.go @@ -0,0 +1,43 @@ +package plugins + +import "fmt" + +// FrameworkRegistry holds framework plugins in registration order. +type FrameworkRegistry struct { + plugins []FrameworkPlugin +} + +// NewFrameworkRegistry creates an empty FrameworkRegistry. +func NewFrameworkRegistry() *FrameworkRegistry { + return &FrameworkRegistry{} +} + +// Register appends a framework plugin to the registry. +func (r *FrameworkRegistry) Register(p FrameworkPlugin) { + r.plugins = append(r.plugins, p) +} + +// Detect iterates plugins in registration order and returns the first +// whose DetectProject returns true. Returns nil if no plugin matches. +func (r *FrameworkRegistry) Detect(dir string) (FrameworkPlugin, error) { + for _, p := range r.plugins { + ok, err := p.DetectProject(dir) + if err != nil { + return nil, fmt.Errorf("plugin %s detect: %w", p.Name(), err) + } + if ok { + return p, nil + } + } + return nil, nil +} + +// Get returns a plugin by name, or nil if not found. +func (r *FrameworkRegistry) Get(name string) FrameworkPlugin { + for _, p := range r.plugins { + if p.Name() == name { + return p + } + } + return nil +} diff --git a/forge-core/registry/index.json b/forge-core/registry/index.json new file mode 100644 index 0000000..fbdb831 --- /dev/null +++ b/forge-core/registry/index.json @@ -0,0 +1,30 @@ +[ + { + "name": "summarize", + "display_name": "Summarize", + "description": "Summarize text or URLs using LLM", + "skill_file": "summarize.md", + "required_env": [], + "one_of_env": ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "XAI_API_KEY", "GEMINI_API_KEY"], + "optional_env": ["FIRECRAWL_API_KEY", "APIFY_API_TOKEN"], + "required_bins": ["summarize"], + "egress_domains": [] + }, + { + "name": "github", + "display_name": "GitHub", + "description": "Create issues, PRs, and query repositories", + "skill_file": "github.md", + "required_env": ["GH_TOKEN"], + "required_bins": ["gh"], + "egress_domains": ["api.github.com", "github.com"] + }, + { + "name": "weather", + "display_name": "Weather", + "description": "Get current weather and forecasts", + "skill_file": "weather.md", + "required_bins": ["curl"], + "egress_domains": ["api.openweathermap.org", "api.weatherapi.com"] + } +] diff --git a/forge-core/registry/registry.go b/forge-core/registry/registry.go new file mode 100644 index 0000000..e21d5e6 --- /dev/null +++ b/forge-core/registry/registry.go @@ -0,0 +1,55 @@ +// Package registry provides an embedded skill registry for the forge init wizard. +// Skills are embedded at compile time and can be vendored into new projects. +package registry + +import ( + "embed" + "encoding/json" +) + +//go:embed skills +var skillFS embed.FS + +//go:embed index.json +var indexJSON []byte + +// SkillInfo describes a skill available in the embedded registry. +type SkillInfo struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + SkillFile string `json:"skill_file"` + RequiredEnv []string `json:"required_env,omitempty"` + OneOfEnv []string `json:"one_of_env,omitempty"` + OptionalEnv []string `json:"optional_env,omitempty"` + RequiredBins []string `json:"required_bins,omitempty"` + EgressDomains []string `json:"egress_domains,omitempty"` +} + +// LoadIndex parses the embedded index.json and returns all registered skills. +func LoadIndex() ([]SkillInfo, error) { + var skills []SkillInfo + if err := json.Unmarshal(indexJSON, &skills); err != nil { + return nil, err + } + return skills, nil +} + +// LoadSkillFile reads the embedded markdown file for the given skill name. +func LoadSkillFile(name string) ([]byte, error) { + return skillFS.ReadFile("skills/" + name + ".md") +} + +// GetSkillByName returns the SkillInfo for a given skill name, or nil if not found. +func GetSkillByName(name string) *SkillInfo { + skills, err := LoadIndex() + if err != nil { + return nil + } + for i := range skills { + if skills[i].Name == name { + return &skills[i] + } + } + return nil +} diff --git a/forge-core/registry/registry_test.go b/forge-core/registry/registry_test.go new file mode 100644 index 0000000..9034602 --- /dev/null +++ b/forge-core/registry/registry_test.go @@ -0,0 +1,109 @@ +package registry + +import ( + "strings" + "testing" +) + +func TestLoadIndex(t *testing.T) { + skills, err := LoadIndex() + if err != nil { + t.Fatalf("LoadIndex() error: %v", err) + } + if len(skills) == 0 { + t.Fatal("LoadIndex() returned empty list") + } + + // Verify expected entries exist + names := make(map[string]bool) + for _, s := range skills { + names[s.Name] = true + if s.DisplayName == "" { + t.Errorf("skill %q has empty display_name", s.Name) + } + if s.Description == "" { + t.Errorf("skill %q has empty description", s.Name) + } + if s.SkillFile == "" { + t.Errorf("skill %q has empty skill_file", s.Name) + } + } + + for _, expected := range []string{"summarize", "github", "weather"} { + if !names[expected] { + t.Errorf("expected skill %q not found in index", expected) + } + } +} + +func TestLoadSkillFile(t *testing.T) { + skills, err := LoadIndex() + if err != nil { + t.Fatalf("LoadIndex() error: %v", err) + } + + for _, s := range skills { + data, err := LoadSkillFile(s.Name) + if err != nil { + t.Errorf("LoadSkillFile(%q) error: %v", s.Name, err) + continue + } + if len(data) == 0 { + t.Errorf("LoadSkillFile(%q) returned empty content", s.Name) + } + // Verify it's valid markdown with at least one tool heading + content := string(data) + if !strings.Contains(content, "## Tool:") { + t.Errorf("LoadSkillFile(%q) missing '## Tool:' heading", s.Name) + } + } +} + +func TestGetSkillByName(t *testing.T) { + s := GetSkillByName("github") + if s == nil { + t.Fatal("GetSkillByName(\"github\") returned nil") + } + if s.DisplayName != "GitHub" { + t.Errorf("expected display_name \"GitHub\", got %q", s.DisplayName) + } + + if GetSkillByName("nonexistent") != nil { + t.Error("GetSkillByName(\"nonexistent\") should return nil") + } +} + +func TestGitHubSkillRequirements(t *testing.T) { + s := GetSkillByName("github") + if s == nil { + t.Fatal("github skill not found") + } + if len(s.RequiredEnv) == 0 { + t.Error("github skill should have required_env") + } + if len(s.RequiredBins) == 0 { + t.Error("github skill should have required_bins") + } + if len(s.EgressDomains) == 0 { + t.Error("github skill should have egress_domains") + } +} + +func TestWeatherSkillRequiredBins(t *testing.T) { + s := GetSkillByName("weather") + if s == nil { + t.Fatal("weather skill not found") + } + if len(s.RequiredBins) == 0 { + t.Error("weather skill should have required_bins") + } + found := false + for _, b := range s.RequiredBins { + if b == "curl" { + found = true + } + } + if !found { + t.Error("weather skill should require curl binary") + } +} diff --git a/forge-core/registry/skills/github.md b/forge-core/registry/skills/github.md new file mode 100644 index 0000000..590e64b --- /dev/null +++ b/forge-core/registry/skills/github.md @@ -0,0 +1,34 @@ +--- +name: github +description: GitHub integration skill +metadata: + forge: + requires: + bins: + - gh + env: + required: + - GH_TOKEN + one_of: [] + optional: [] +--- +## Tool: github_create_issue + +Create a GitHub issue. + +**Input:** repo (string), title (string), body (string) +**Output:** Issue URL + +## Tool: github_list_issues + +List open issues for a repository. + +**Input:** repo (string), state (string: open/closed) +**Output:** List of issues with number, title, and state + +## Tool: github_create_pr + +Create a pull request. + +**Input:** repo (string), title (string), body (string), head (string), base (string) +**Output:** Pull request URL diff --git a/forge-core/registry/skills/summarize.md b/forge-core/registry/skills/summarize.md new file mode 100644 index 0000000..c3d439c --- /dev/null +++ b/forge-core/registry/skills/summarize.md @@ -0,0 +1,32 @@ +--- +name: summarize +description: Summarize text or URLs using LLM +metadata: + forge: + requires: + bins: + - summarize + env: + required: [] + one_of: + - OPENAI_API_KEY + - ANTHROPIC_API_KEY + - XAI_API_KEY + - GEMINI_API_KEY + optional: + - FIRECRAWL_API_KEY + - APIFY_API_TOKEN +--- +## Tool: summarize_text + +Summarize a block of text into key points. + +**Input:** text (string) - The text to summarize +**Output:** A concise summary of the input text + +## Tool: summarize_url + +Fetch and summarize the content of a URL. + +**Input:** url (string) - The URL to fetch and summarize +**Output:** A concise summary of the page content diff --git a/forge-core/registry/skills/weather.md b/forge-core/registry/skills/weather.md new file mode 100644 index 0000000..32a90fa --- /dev/null +++ b/forge-core/registry/skills/weather.md @@ -0,0 +1,26 @@ +--- +name: weather +description: Weather data skill +metadata: + forge: + requires: + bins: + - curl + env: + required: [] + one_of: [] + optional: [] +--- +## Tool: weather_current + +Get current weather for a location. + +**Input:** location (string) - City name or coordinates +**Output:** Current temperature, conditions, humidity, and wind speed + +## Tool: weather_forecast + +Get weather forecast for a location. + +**Input:** location (string), days (integer: 1-7) +**Output:** Daily forecast with high/low temperatures and conditions diff --git a/forge-core/runtime/agentcard.go b/forge-core/runtime/agentcard.go new file mode 100644 index 0000000..dd31423 --- /dev/null +++ b/forge-core/runtime/agentcard.go @@ -0,0 +1,65 @@ +package runtime + +import ( + "github.com/initializ/forge/forge-core/a2a" + "github.com/initializ/forge/forge-core/agentspec" + "github.com/initializ/forge/forge-core/types" +) + +// AgentCardFromSpec constructs an AgentCard from an AgentSpec and a base URL. +// The baseURL should be a fully-formed URL (e.g. "http://localhost:8080"). +func AgentCardFromSpec(spec *agentspec.AgentSpec, baseURL string) *a2a.AgentCard { + card := &a2a.AgentCard{ + Name: spec.Name, + Description: spec.Description, + URL: baseURL, + } + + // Convert tools to skills + for _, t := range spec.Tools { + card.Skills = append(card.Skills, a2a.Skill{ + ID: t.Name, + Name: t.Name, + Description: t.Description, + }) + } + + // Copy A2A capabilities if present + if spec.A2A != nil { + for _, s := range spec.A2A.Skills { + card.Skills = append(card.Skills, a2a.Skill{ + ID: s.ID, + Name: s.Name, + Description: s.Description, + Tags: s.Tags, + }) + } + if spec.A2A.Capabilities != nil { + card.Capabilities = &a2a.AgentCapabilities{ + Streaming: spec.A2A.Capabilities.Streaming, + PushNotifications: spec.A2A.Capabilities.PushNotifications, + StateTransitionHistory: spec.A2A.Capabilities.StateTransitionHistory, + } + } + } + + return card +} + +// AgentCardFromConfig constructs an AgentCard from a ForgeConfig and a base URL. +// The baseURL should be a fully-formed URL (e.g. "http://localhost:8080"). +func AgentCardFromConfig(cfg *types.ForgeConfig, baseURL string) *a2a.AgentCard { + card := &a2a.AgentCard{ + Name: cfg.AgentID, + URL: baseURL, + } + + for _, t := range cfg.Tools { + card.Skills = append(card.Skills, a2a.Skill{ + ID: t.Name, + Name: t.Name, + }) + } + + return card +} diff --git a/forge-core/runtime/config.go b/forge-core/runtime/config.go new file mode 100644 index 0000000..ef84431 --- /dev/null +++ b/forge-core/runtime/config.go @@ -0,0 +1,119 @@ +package runtime + +import ( + "github.com/initializ/forge/forge-core/llm" + "github.com/initializ/forge/forge-core/types" +) + +// ModelConfig holds the resolved model provider and configuration. +type ModelConfig struct { + Provider string + Client llm.ClientConfig +} + +// ResolveModelConfig resolves the LLM provider and configuration from multiple +// sources with the following priority (highest wins): +// +// 1. CLI --provider flag (providerOverride) +// 2. Environment variables: FORGE_MODEL_PROVIDER, OPENAI_API_KEY, ANTHROPIC_API_KEY, LLM_API_KEY +// 3. forge.yaml model section +// +// Returns nil if no provider could be resolved. +func ResolveModelConfig(cfg *types.ForgeConfig, envVars map[string]string, providerOverride string) *ModelConfig { + mc := &ModelConfig{} + + // Start with forge.yaml model config + if cfg.Model.Provider != "" { + mc.Provider = cfg.Model.Provider + mc.Client.Model = cfg.Model.Name + } + + // Apply env vars + if p := envVars["FORGE_MODEL_PROVIDER"]; p != "" { + mc.Provider = p + } + if m := envVars["MODEL_NAME"]; m != "" { + mc.Client.Model = m + } + + // Resolve API key based on provider + resolveAPIKey(mc, envVars) + + // CLI override is highest priority + if providerOverride != "" { + mc.Provider = providerOverride + resolveAPIKey(mc, envVars) + } + + // Auto-detect provider from available API keys if not set + if mc.Provider == "" { + if envVars["OPENAI_API_KEY"] != "" { + mc.Provider = "openai" + mc.Client.APIKey = envVars["OPENAI_API_KEY"] + } else if envVars["ANTHROPIC_API_KEY"] != "" { + mc.Provider = "anthropic" + mc.Client.APIKey = envVars["ANTHROPIC_API_KEY"] + } else if envVars["GEMINI_API_KEY"] != "" { + mc.Provider = "gemini" + mc.Client.APIKey = envVars["GEMINI_API_KEY"] + } + } + + // Apply base URL overrides + if u := envVars["OPENAI_BASE_URL"]; u != "" && mc.Provider == "openai" { + mc.Client.BaseURL = u + } + if u := envVars["ANTHROPIC_BASE_URL"]; u != "" && mc.Provider == "anthropic" { + mc.Client.BaseURL = u + } + if u := envVars["OLLAMA_BASE_URL"]; u != "" && mc.Provider == "ollama" { + mc.Client.BaseURL = u + } + + // Return nil if no provider could be resolved + if mc.Provider == "" { + return nil + } + + // Set default models per provider if not specified + if mc.Client.Model == "" { + switch mc.Provider { + case "openai": + mc.Client.Model = "gpt-4o" + case "anthropic": + mc.Client.Model = "claude-sonnet-4-20250514" + case "gemini": + mc.Client.Model = "gemini-2.5-flash" + case "ollama": + mc.Client.Model = "llama3" + } + } + + return mc +} + +func resolveAPIKey(mc *ModelConfig, envVars map[string]string) { + switch mc.Provider { + case "openai": + if k := envVars["OPENAI_API_KEY"]; k != "" { + mc.Client.APIKey = k + } else if k := envVars["LLM_API_KEY"]; k != "" { + mc.Client.APIKey = k + } + case "anthropic": + if k := envVars["ANTHROPIC_API_KEY"]; k != "" { + mc.Client.APIKey = k + } else if k := envVars["LLM_API_KEY"]; k != "" { + mc.Client.APIKey = k + } + case "gemini": + if k := envVars["GEMINI_API_KEY"]; k != "" { + mc.Client.APIKey = k + } else if k := envVars["LLM_API_KEY"]; k != "" { + mc.Client.APIKey = k + } + case "ollama": + // Ollama doesn't need an API key + mc.Client.APIKey = "ollama" + } +} diff --git a/forge-core/runtime/env.go b/forge-core/runtime/env.go new file mode 100644 index 0000000..f3d6c1b --- /dev/null +++ b/forge-core/runtime/env.go @@ -0,0 +1,40 @@ +package runtime + +import ( + "bufio" + "io" + "strings" +) + +// ParseEnvVars reads key=value pairs from an io.Reader. +// Supports # comments, double/single quotes, and export prefix. +func ParseEnvVars(r io.Reader) (map[string]string, error) { + env := make(map[string]string) + scanner := bufio.NewScanner(r) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + // Strip optional "export " prefix + line = strings.TrimPrefix(line, "export ") + line = strings.TrimSpace(line) + + key, val, ok := strings.Cut(line, "=") + if !ok { + continue + } + key = strings.TrimSpace(key) + val = strings.TrimSpace(val) + + // Strip matching quotes + if len(val) >= 2 { + if (val[0] == '"' && val[len(val)-1] == '"') || + (val[0] == '\'' && val[len(val)-1] == '\'') { + val = val[1 : len(val)-1] + } + } + env[key] = val + } + return env, scanner.Err() +} diff --git a/forge-core/runtime/executor.go b/forge-core/runtime/executor.go new file mode 100644 index 0000000..a7d3dc3 --- /dev/null +++ b/forge-core/runtime/executor.go @@ -0,0 +1,20 @@ +package runtime + +import ( + "context" + + "github.com/initializ/forge/forge-core/a2a" +) + +// AgentExecutor processes individual messages and returns responses. +// Unlike AgentRuntime (which manages subprocess lifecycle), the executor +// focuses solely on message-level processing. The handler (runner.go) +// manages task lifecycle (submitted -> working -> completed/failed). +type AgentExecutor interface { + // Execute processes a message in the context of a task and returns a response. + Execute(ctx context.Context, task *a2a.Task, msg *a2a.Message) (*a2a.Message, error) + // ExecuteStream processes a message and returns a channel of response messages. + ExecuteStream(ctx context.Context, task *a2a.Task, msg *a2a.Message) (<-chan *a2a.Message, error) + // Close releases any resources held by the executor. + Close() error +} diff --git a/forge-core/runtime/guardrails.go b/forge-core/runtime/guardrails.go new file mode 100644 index 0000000..0c31f33 --- /dev/null +++ b/forge-core/runtime/guardrails.go @@ -0,0 +1,137 @@ +package runtime + +import ( + "fmt" + "regexp" + "strings" + + "github.com/initializ/forge/forge-core/a2a" + "github.com/initializ/forge/forge-core/agentspec" +) + +// GuardrailEngine checks inbound and outbound messages against policy rules. +type GuardrailEngine struct { + scaffold *agentspec.PolicyScaffold + enforce bool + logger Logger +} + +// NewGuardrailEngine creates a GuardrailEngine. If scaffold is nil, a default +// is used. When enforce is true, violations return errors; otherwise they are +// logged as warnings. +func NewGuardrailEngine(scaffold *agentspec.PolicyScaffold, enforce bool, logger Logger) *GuardrailEngine { + if scaffold == nil { + scaffold = &agentspec.PolicyScaffold{} + } + return &GuardrailEngine{scaffold: scaffold, enforce: enforce, logger: logger} +} + +// CheckInbound validates an inbound (user) message against guardrails. +func (g *GuardrailEngine) CheckInbound(msg *a2a.Message) error { + return g.check(msg, "inbound") +} + +// CheckOutbound validates an outbound (agent) message against guardrails. +func (g *GuardrailEngine) CheckOutbound(msg *a2a.Message) error { + return g.check(msg, "outbound") +} + +func (g *GuardrailEngine) check(msg *a2a.Message, direction string) error { + text := extractText(msg) + if text == "" { + return nil + } + + for _, gr := range g.scaffold.Guardrails { + var err error + switch gr.Type { + case "content_filter": + err = g.checkContentFilter(text, gr) + case "no_pii": + err = g.checkNoPII(text) + case "jailbreak_protection": + err = g.checkJailbreak(text) + default: + continue + } + if err != nil { + if g.enforce { + return fmt.Errorf("guardrail %s (%s): %w", gr.Type, direction, err) + } + g.logger.Warn("guardrail violation", map[string]any{ + "guardrail": gr.Type, + "direction": direction, + "detail": err.Error(), + }) + } + } + return nil +} + +func extractText(msg *a2a.Message) string { + var parts []string + for _, p := range msg.Parts { + if p.Kind == a2a.PartKindText && p.Text != "" { + parts = append(parts, p.Text) + } + } + return strings.Join(parts, " ") +} + +func (g *GuardrailEngine) checkContentFilter(text string, gr agentspec.Guardrail) error { + // Use blocked words from config, or defaults + blocked := []string{"BLOCKED_CONTENT"} + if gr.Config != nil { + if words, ok := gr.Config["blocked_words"]; ok { + if list, ok := words.([]any); ok { + blocked = blocked[:0] + for _, w := range list { + if s, ok := w.(string); ok { + blocked = append(blocked, s) + } + } + } + } + } + lower := strings.ToLower(text) + for _, word := range blocked { + if strings.Contains(lower, strings.ToLower(word)) { + return fmt.Errorf("content filter: blocked word %q detected", word) + } + } + return nil +} + +var piiPatterns = []*regexp.Regexp{ + regexp.MustCompile(`[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}`), // email + regexp.MustCompile(`\b\d{3}[-.]?\d{3}[-.]?\d{4}\b`), // phone + regexp.MustCompile(`\b\d{3}-\d{2}-\d{4}\b`), // SSN +} + +func (g *GuardrailEngine) checkNoPII(text string) error { + for _, re := range piiPatterns { + if re.MatchString(text) { + return fmt.Errorf("PII pattern detected: %s", re.String()) + } + } + return nil +} + +var jailbreakPhrases = []string{ + "ignore previous instructions", + "ignore all instructions", + "disregard your instructions", + "forget your rules", + "you are now", + "act as if you have no restrictions", +} + +func (g *GuardrailEngine) checkJailbreak(text string) error { + lower := strings.ToLower(text) + for _, phrase := range jailbreakPhrases { + if strings.Contains(lower, phrase) { + return fmt.Errorf("jailbreak pattern detected: %q", phrase) + } + } + return nil +} diff --git a/forge-core/runtime/hooks.go b/forge-core/runtime/hooks.go new file mode 100644 index 0000000..f7c9e19 --- /dev/null +++ b/forge-core/runtime/hooks.go @@ -0,0 +1,59 @@ +package runtime + +import ( + "context" + + "github.com/initializ/forge/forge-core/llm" +) + +// HookPoint identifies when a hook fires in the agent loop. +type HookPoint int + +const ( + BeforeLLMCall HookPoint = iota + AfterLLMCall + BeforeToolExec + AfterToolExec + OnError +) + +// HookContext carries data available to hooks at each hook point. +type HookContext struct { + Messages []llm.ChatMessage + Response *llm.ChatResponse + ToolName string + ToolInput string + ToolOutput string + Error error +} + +// Hook is a function invoked at a specific point in the agent loop. +type Hook func(ctx context.Context, hctx *HookContext) error + +// HookRegistry manages registered hooks for each hook point. +type HookRegistry struct { + hooks map[HookPoint][]Hook +} + +// NewHookRegistry creates an empty HookRegistry. +func NewHookRegistry() *HookRegistry { + return &HookRegistry{ + hooks: make(map[HookPoint][]Hook), + } +} + +// Register adds a hook for the given point. Hooks fire in registration order. +func (r *HookRegistry) Register(point HookPoint, h Hook) { + r.hooks[point] = append(r.hooks[point], h) +} + +// Fire invokes all hooks registered for the given point in order. +// If any hook returns an error, execution stops and the error is returned. +func (r *HookRegistry) Fire(ctx context.Context, point HookPoint, hctx *HookContext) error { + for _, h := range r.hooks[point] { + if err := h(ctx, hctx); err != nil { + return err + } + } + return nil +} diff --git a/forge-core/runtime/logger.go b/forge-core/runtime/logger.go new file mode 100644 index 0000000..00d70e7 --- /dev/null +++ b/forge-core/runtime/logger.go @@ -0,0 +1,56 @@ +package runtime + +import ( + "encoding/json" + "io" + "sync" + "time" +) + +// Logger defines the structured logging interface for the runtime. +type Logger interface { + Info(msg string, fields map[string]any) + Warn(msg string, fields map[string]any) + Error(msg string, fields map[string]any) + Debug(msg string, fields map[string]any) +} + +// JSONLogger writes structured JSON log entries to an io.Writer. +type JSONLogger struct { + mu sync.Mutex + w io.Writer + verbose bool +} + +// NewJSONLogger creates a JSONLogger writing to w. Debug entries are only +// emitted when verbose is true. +func NewJSONLogger(w io.Writer, verbose bool) *JSONLogger { + return &JSONLogger{w: w, verbose: verbose} +} + +func (l *JSONLogger) Info(msg string, fields map[string]any) { l.log("info", msg, fields) } +func (l *JSONLogger) Warn(msg string, fields map[string]any) { l.log("warn", msg, fields) } +func (l *JSONLogger) Error(msg string, fields map[string]any) { l.log("error", msg, fields) } + +func (l *JSONLogger) Debug(msg string, fields map[string]any) { + if !l.verbose { + return + } + l.log("debug", msg, fields) +} + +func (l *JSONLogger) log(level, msg string, fields map[string]any) { + entry := make(map[string]any, len(fields)+3) + entry["time"] = time.Now().UTC().Format(time.RFC3339) + entry["level"] = level + entry["msg"] = msg + for k, v := range fields { + entry[k] = v + } + + l.mu.Lock() + defer l.mu.Unlock() + data, _ := json.Marshal(entry) + data = append(data, '\n') + l.w.Write(data) //nolint:errcheck +} diff --git a/forge-core/runtime/loop.go b/forge-core/runtime/loop.go new file mode 100644 index 0000000..5f78887 --- /dev/null +++ b/forge-core/runtime/loop.go @@ -0,0 +1,217 @@ +package runtime + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/initializ/forge/forge-core/a2a" + "github.com/initializ/forge/forge-core/llm" +) + +// ToolExecutor provides tool execution capabilities to the engine. +// The tools.Registry satisfies this interface via Go structural typing. +type ToolExecutor interface { + Execute(ctx context.Context, name string, arguments json.RawMessage) (string, error) + ToolDefinitions() []llm.ToolDefinition +} + +// LLMExecutor implements AgentExecutor using an LLM client with tool calling. +type LLMExecutor struct { + client llm.Client + tools ToolExecutor + hooks *HookRegistry + systemPrompt string + maxIter int +} + +// LLMExecutorConfig configures the LLM executor. +type LLMExecutorConfig struct { + Client llm.Client + Tools ToolExecutor + Hooks *HookRegistry + SystemPrompt string + MaxIterations int +} + +// NewLLMExecutor creates a new LLMExecutor with the given configuration. +func NewLLMExecutor(cfg LLMExecutorConfig) *LLMExecutor { + maxIter := cfg.MaxIterations + if maxIter == 0 { + maxIter = 10 + } + hooks := cfg.Hooks + if hooks == nil { + hooks = NewHookRegistry() + } + return &LLMExecutor{ + client: cfg.Client, + tools: cfg.Tools, + hooks: hooks, + systemPrompt: cfg.SystemPrompt, + maxIter: maxIter, + } +} + +// Execute processes a message through the LLM agent loop. +func (e *LLMExecutor) Execute(ctx context.Context, task *a2a.Task, msg *a2a.Message) (*a2a.Message, error) { + mem := NewMemory(e.systemPrompt, 0) + + // Load task history into memory + for _, histMsg := range task.History { + mem.Append(a2aMessageToLLM(histMsg)) + } + + // Append the new user message + mem.Append(a2aMessageToLLM(*msg)) + + // Build tool definitions + var toolDefs []llm.ToolDefinition + if e.tools != nil { + toolDefs = e.tools.ToolDefinitions() + } + + // Agent loop + for i := 0; i < e.maxIter; i++ { + messages := mem.Messages() + + // Fire BeforeLLMCall hook + if err := e.hooks.Fire(ctx, BeforeLLMCall, &HookContext{Messages: messages}); err != nil { + return nil, fmt.Errorf("before LLM call hook: %w", err) + } + + // Call LLM + req := &llm.ChatRequest{ + Messages: messages, + Tools: toolDefs, + } + + resp, err := e.client.Chat(ctx, req) + if err != nil { + _ = e.hooks.Fire(ctx, OnError, &HookContext{Error: err}) + // Return user-friendly error (raw error is already logged via OnError hook) + return nil, fmt.Errorf("something went wrong while processing your request, please try again") + } + + // Fire AfterLLMCall hook + if err := e.hooks.Fire(ctx, AfterLLMCall, &HookContext{ + Messages: messages, + Response: resp, + }); err != nil { + return nil, fmt.Errorf("after LLM call hook: %w", err) + } + + // Append assistant message to memory + mem.Append(resp.Message) + + // Check if we're done (no tool calls) + if resp.FinishReason == "stop" || len(resp.Message.ToolCalls) == 0 { + return llmMessageToA2A(resp.Message), nil + } + + // Execute tool calls + if e.tools == nil { + return llmMessageToA2A(resp.Message), nil + } + + for _, tc := range resp.Message.ToolCalls { + // Fire BeforeToolExec hook + if err := e.hooks.Fire(ctx, BeforeToolExec, &HookContext{ + ToolName: tc.Function.Name, + ToolInput: tc.Function.Arguments, + }); err != nil { + return nil, fmt.Errorf("before tool exec hook: %w", err) + } + + // Execute tool + result, execErr := e.tools.Execute(ctx, tc.Function.Name, json.RawMessage(tc.Function.Arguments)) + if execErr != nil { + result = fmt.Sprintf("Error executing tool %s: %s", tc.Function.Name, execErr.Error()) + } + + // Truncate oversized tool results to avoid LLM API errors. + // Use a limit below maxMessageChars so the suffix fits within the memory cap. + const maxToolResultChars = 49_000 // ~12K tokens, leaves room for truncation suffix + if len(result) > maxToolResultChars { + result = result[:maxToolResultChars] + "\n\n[OUTPUT TRUNCATED — original length: " + strconv.Itoa(len(result)) + " chars]" + } + + // Fire AfterToolExec hook + if err := e.hooks.Fire(ctx, AfterToolExec, &HookContext{ + ToolName: tc.Function.Name, + ToolInput: tc.Function.Arguments, + ToolOutput: result, + Error: execErr, + }); err != nil { + return nil, fmt.Errorf("after tool exec hook: %w", err) + } + + // Append tool result to memory + mem.Append(llm.ChatMessage{ + Role: llm.RoleTool, + Content: result, + ToolCallID: tc.ID, + Name: tc.Function.Name, + }) + } + } + + return nil, fmt.Errorf("agent loop exceeded maximum iterations (%d)", e.maxIter) +} + +// ExecuteStream runs the tool-calling loop non-streaming, then emits the final +// response as a single message on the channel. True word-by-word streaming is v2. +func (e *LLMExecutor) ExecuteStream(ctx context.Context, task *a2a.Task, msg *a2a.Message) (<-chan *a2a.Message, error) { + ch := make(chan *a2a.Message, 1) + go func() { + defer close(ch) + resp, err := e.Execute(ctx, task, msg) + if err != nil { + ch <- &a2a.Message{ + Role: a2a.MessageRoleAgent, + Parts: []a2a.Part{a2a.NewTextPart("Error: " + err.Error())}, + } + return + } + ch <- resp + }() + return ch, nil +} + +// Close is a no-op for LLMExecutor. +func (e *LLMExecutor) Close() error { return nil } + +// a2aMessageToLLM converts an A2A message to an LLM chat message. +func a2aMessageToLLM(msg a2a.Message) llm.ChatMessage { + role := llm.RoleUser + if msg.Role == a2a.MessageRoleAgent { + role = llm.RoleAssistant + } + + var textParts []string + for _, p := range msg.Parts { + if p.Kind == a2a.PartKindText && p.Text != "" { + textParts = append(textParts, p.Text) + } + } + + return llm.ChatMessage{ + Role: role, + Content: strings.Join(textParts, "\n"), + } +} + +// llmMessageToA2A converts an LLM chat message to an A2A message. +func llmMessageToA2A(msg llm.ChatMessage) *a2a.Message { + role := a2a.MessageRoleAgent + if msg.Role == llm.RoleUser { + role = a2a.MessageRoleUser + } + + return &a2a.Message{ + Role: role, + Parts: []a2a.Part{a2a.NewTextPart(msg.Content)}, + } +} diff --git a/forge-core/runtime/loop_test.go b/forge-core/runtime/loop_test.go new file mode 100644 index 0000000..275183c --- /dev/null +++ b/forge-core/runtime/loop_test.go @@ -0,0 +1,252 @@ +package runtime + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/initializ/forge/forge-core/a2a" + "github.com/initializ/forge/forge-core/llm" +) + +// mockLLMClient implements llm.Client for testing. +type mockLLMClient struct { + chatFunc func(ctx context.Context, req *llm.ChatRequest) (*llm.ChatResponse, error) +} + +func (m *mockLLMClient) Chat(ctx context.Context, req *llm.ChatRequest) (*llm.ChatResponse, error) { + return m.chatFunc(ctx, req) +} + +func (m *mockLLMClient) ChatStream(ctx context.Context, req *llm.ChatRequest) (<-chan llm.StreamDelta, error) { + return nil, fmt.Errorf("not implemented") +} + +func (m *mockLLMClient) ModelID() string { return "test-model" } + +// mockToolExecutor implements ToolExecutor for testing. +type mockToolExecutor struct { + executeFunc func(ctx context.Context, name string, arguments json.RawMessage) (string, error) + toolDefs []llm.ToolDefinition +} + +func (m *mockToolExecutor) Execute(ctx context.Context, name string, arguments json.RawMessage) (string, error) { + return m.executeFunc(ctx, name, arguments) +} + +func (m *mockToolExecutor) ToolDefinitions() []llm.ToolDefinition { + return m.toolDefs +} + +func TestToolResultTruncation(t *testing.T) { + // Generate a tool result that exceeds maxToolResultChars (50,000) + largeResult := strings.Repeat("x", 60_000) + + callCount := 0 + var capturedMessages []llm.ChatMessage + + client := &mockLLMClient{ + chatFunc: func(ctx context.Context, req *llm.ChatRequest) (*llm.ChatResponse, error) { + callCount++ + capturedMessages = req.Messages + + if callCount == 1 { + // First call: ask for a tool call + return &llm.ChatResponse{ + Message: llm.ChatMessage{ + Role: llm.RoleAssistant, + ToolCalls: []llm.ToolCall{ + { + ID: "call_1", + Type: "function", + Function: llm.FunctionCall{ + Name: "big_tool", + Arguments: `{}`, + }, + }, + }, + }, + FinishReason: "tool_calls", + }, nil + } + + // Second call: return final response + return &llm.ChatResponse{ + Message: llm.ChatMessage{ + Role: llm.RoleAssistant, + Content: "Done", + }, + FinishReason: "stop", + }, nil + }, + } + + tools := &mockToolExecutor{ + executeFunc: func(ctx context.Context, name string, arguments json.RawMessage) (string, error) { + return largeResult, nil + }, + toolDefs: []llm.ToolDefinition{ + {Type: "function", Function: llm.FunctionSchema{Name: "big_tool"}}, + }, + } + + executor := NewLLMExecutor(LLMExecutorConfig{ + Client: client, + Tools: tools, + }) + + task := &a2a.Task{ID: "test-1"} + msg := &a2a.Message{ + Role: a2a.MessageRoleUser, + Parts: []a2a.Part{a2a.NewTextPart("do it")}, + } + + resp, err := executor.Execute(context.Background(), task, msg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp == nil { + t.Fatal("expected response, got nil") + } + + // Verify the tool result sent to the LLM on the second call was truncated + var toolMsg *llm.ChatMessage + for i := range capturedMessages { + if capturedMessages[i].Role == llm.RoleTool { + toolMsg = &capturedMessages[i] + } + } + if toolMsg == nil { + t.Fatal("expected a tool message in captured messages") + } + + if len(toolMsg.Content) >= 60_000 { + t.Errorf("tool result was not truncated: got %d chars", len(toolMsg.Content)) + } + + if !strings.Contains(toolMsg.Content, "[OUTPUT TRUNCATED") { + t.Error("truncated tool result missing [OUTPUT TRUNCATED] marker") + } + + if !strings.Contains(toolMsg.Content, "60000") { + t.Error("truncated tool result should contain original length") + } +} + +func TestToolResultUnderLimitNotTruncated(t *testing.T) { + smallResult := strings.Repeat("y", 1000) + + callCount := 0 + var capturedMessages []llm.ChatMessage + + client := &mockLLMClient{ + chatFunc: func(ctx context.Context, req *llm.ChatRequest) (*llm.ChatResponse, error) { + callCount++ + capturedMessages = req.Messages + + if callCount == 1 { + return &llm.ChatResponse{ + Message: llm.ChatMessage{ + Role: llm.RoleAssistant, + ToolCalls: []llm.ToolCall{ + { + ID: "call_1", + Type: "function", + Function: llm.FunctionCall{ + Name: "small_tool", + Arguments: `{}`, + }, + }, + }, + }, + FinishReason: "tool_calls", + }, nil + } + + return &llm.ChatResponse{ + Message: llm.ChatMessage{ + Role: llm.RoleAssistant, + Content: "Done", + }, + FinishReason: "stop", + }, nil + }, + } + + tools := &mockToolExecutor{ + executeFunc: func(ctx context.Context, name string, arguments json.RawMessage) (string, error) { + return smallResult, nil + }, + toolDefs: []llm.ToolDefinition{ + {Type: "function", Function: llm.FunctionSchema{Name: "small_tool"}}, + }, + } + + executor := NewLLMExecutor(LLMExecutorConfig{ + Client: client, + Tools: tools, + }) + + task := &a2a.Task{ID: "test-2"} + msg := &a2a.Message{ + Role: a2a.MessageRoleUser, + Parts: []a2a.Part{a2a.NewTextPart("do it")}, + } + + _, err := executor.Execute(context.Background(), task, msg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify the tool result was NOT truncated + var toolMsg *llm.ChatMessage + for i := range capturedMessages { + if capturedMessages[i].Role == llm.RoleTool { + toolMsg = &capturedMessages[i] + } + } + if toolMsg == nil { + t.Fatal("expected a tool message in captured messages") + } + + if toolMsg.Content != smallResult { + t.Errorf("expected exact small result, got content of length %d", len(toolMsg.Content)) + } +} + +func TestLLMErrorReturnsFriendlyMessage(t *testing.T) { + client := &mockLLMClient{ + chatFunc: func(ctx context.Context, req *llm.ChatRequest) (*llm.ChatResponse, error) { + return nil, fmt.Errorf("openai error (status 400): {\"error\":{\"message\":\"Invalid parameter\"}}") + }, + } + + executor := NewLLMExecutor(LLMExecutorConfig{ + Client: client, + }) + + task := &a2a.Task{ID: "test-3"} + msg := &a2a.Message{ + Role: a2a.MessageRoleUser, + Parts: []a2a.Part{a2a.NewTextPart("hello")}, + } + + _, err := executor.Execute(context.Background(), task, msg) + if err == nil { + t.Fatal("expected error, got nil") + } + + // Error should be user-friendly, not containing raw API details + errStr := err.Error() + if strings.Contains(errStr, "openai") { + t.Errorf("error should not contain raw API details, got: %s", errStr) + } + if strings.Contains(errStr, "400") { + t.Errorf("error should not contain status codes, got: %s", errStr) + } + if !strings.Contains(errStr, "something went wrong") { + t.Errorf("error should contain friendly message, got: %s", errStr) + } +} diff --git a/forge-core/runtime/memory.go b/forge-core/runtime/memory.go new file mode 100644 index 0000000..5189fc9 --- /dev/null +++ b/forge-core/runtime/memory.go @@ -0,0 +1,110 @@ +package runtime + +import ( + "sync" + + "github.com/initializ/forge/forge-core/llm" +) + +// Memory manages per-task conversation history with token budget tracking. +type Memory struct { + mu sync.Mutex + systemPrompt string + messages []llm.ChatMessage + maxChars int // approximate token budget: 1 token ~ 4 chars +} + +// NewMemory creates a Memory with the given system prompt and character budget. +// If maxChars is 0, a default of 200000 (~50K tokens) is used. The budget must +// comfortably exceed the per-message truncation cap so that a single tool result +// plus its surrounding messages fit without triggering aggressive trimming. +func NewMemory(systemPrompt string, maxChars int) *Memory { + if maxChars == 0 { + maxChars = 200_000 + } + return &Memory{ + systemPrompt: systemPrompt, + maxChars: maxChars, + } +} + +// maxMessageChars is the per-message size cap (defense in depth). +const maxMessageChars = 50_000 + +// Append adds a message to the conversation history and trims if over budget. +// Individual messages exceeding maxMessageChars are truncated as a safety net. +func (m *Memory) Append(msg llm.ChatMessage) { + m.mu.Lock() + defer m.mu.Unlock() + if len(msg.Content) > maxMessageChars { + msg.Content = msg.Content[:maxMessageChars] + "\n[TRUNCATED]" + } + m.messages = append(m.messages, msg) + m.trim() +} + +// Messages returns the full message list with the system prompt prepended. +func (m *Memory) Messages() []llm.ChatMessage { + m.mu.Lock() + defer m.mu.Unlock() + + msgs := make([]llm.ChatMessage, 0, len(m.messages)+1) + if m.systemPrompt != "" { + msgs = append(msgs, llm.ChatMessage{ + Role: llm.RoleSystem, + Content: m.systemPrompt, + }) + } + msgs = append(msgs, m.messages...) + return msgs +} + +// Reset clears the conversation history (keeps the system prompt). +func (m *Memory) Reset() { + m.mu.Lock() + defer m.mu.Unlock() + m.messages = nil +} + +// trim removes oldest messages when the total character count exceeds budget. +// Messages are removed in structural groups to maintain valid sequences: +// - An assistant message with tool_calls is always removed together with its +// subsequent tool-result messages (they form one atomic group). +// - Orphaned tool-result messages at the front are removed as a group. +// - A plain user/assistant message is a single-message group. +// +// Trimming stops if removing the next group would leave zero messages, +// preserving at least the last complete group even if it exceeds the budget. +func (m *Memory) trim() { + for m.totalChars() > m.maxChars && len(m.messages) > 1 { + // Determine the size of the first message group. + end := 1 + if m.messages[0].Role == llm.RoleTool { + // Orphaned tool results — remove all contiguous tool messages. + for end < len(m.messages) && m.messages[end].Role == llm.RoleTool { + end++ + } + } else if len(m.messages[0].ToolCalls) > 0 { + // Assistant with tool_calls — include all following tool results. + for end < len(m.messages) && m.messages[end].Role == llm.RoleTool { + end++ + } + } + // Don't remove everything — keep at least one complete group. + if end >= len(m.messages) { + break + } + m.messages = m.messages[end:] + } +} + +func (m *Memory) totalChars() int { + total := len(m.systemPrompt) + for _, msg := range m.messages { + total += len(msg.Content) + len(msg.Role) + for _, tc := range msg.ToolCalls { + total += len(tc.Function.Name) + len(tc.Function.Arguments) + } + } + return total +} diff --git a/forge-core/runtime/memory_test.go b/forge-core/runtime/memory_test.go new file mode 100644 index 0000000..3babbe7 --- /dev/null +++ b/forge-core/runtime/memory_test.go @@ -0,0 +1,204 @@ +package runtime + +import ( + "strings" + "testing" + + "github.com/initializ/forge/forge-core/llm" +) + +func TestAppendTruncatesOversizedMessage(t *testing.T) { + mem := NewMemory("system prompt", 0) + + largeContent := strings.Repeat("a", 60_000) + mem.Append(llm.ChatMessage{ + Role: llm.RoleUser, + Content: largeContent, + }) + + msgs := mem.Messages() + // msgs[0] is system, msgs[1] is the user message + if len(msgs) != 2 { + t.Fatalf("expected 2 messages (system + user), got %d", len(msgs)) + } + + userMsg := msgs[1] + if len(userMsg.Content) >= 60_000 { + t.Errorf("message was not truncated: got %d chars", len(userMsg.Content)) + } + + if !strings.HasSuffix(userMsg.Content, "\n[TRUNCATED]") { + t.Error("truncated message missing [TRUNCATED] suffix") + } + + // Should be maxMessageChars + len("\n[TRUNCATED]") + expectedLen := maxMessageChars + len("\n[TRUNCATED]") + if len(userMsg.Content) != expectedLen { + t.Errorf("expected truncated length %d, got %d", expectedLen, len(userMsg.Content)) + } +} + +func TestAppendDoesNotTruncateSmallMessage(t *testing.T) { + mem := NewMemory("system prompt", 0) + + content := "hello world" + mem.Append(llm.ChatMessage{ + Role: llm.RoleUser, + Content: content, + }) + + msgs := mem.Messages() + if len(msgs) != 2 { + t.Fatalf("expected 2 messages, got %d", len(msgs)) + } + + if msgs[1].Content != content { + t.Errorf("expected content %q, got %q", content, msgs[1].Content) + } +} + +func TestAppendMessageAtExactLimit(t *testing.T) { + mem := NewMemory("", 0) + + content := strings.Repeat("b", maxMessageChars) + mem.Append(llm.ChatMessage{ + Role: llm.RoleUser, + Content: content, + }) + + msgs := mem.Messages() + if msgs[0].Content != content { + t.Error("message at exact limit should not be truncated") + } +} + +func TestTrimRemovesOldMessages(t *testing.T) { + // Use a small budget to force trimming + mem := NewMemory("", 100) + + // Add messages that exceed the budget + for i := 0; i < 10; i++ { + mem.Append(llm.ChatMessage{ + Role: llm.RoleUser, + Content: strings.Repeat("x", 20), + }) + } + + msgs := mem.Messages() + // Total chars should be within budget (at least the last message is kept) + totalChars := 0 + for _, msg := range msgs { + totalChars += len(msg.Content) + len(msg.Role) + } + + // Memory should have trimmed — should have fewer than 10 messages + if len(msgs) >= 10 { + t.Errorf("expected trimming to reduce messages, got %d", len(msgs)) + } +} + +func TestTrimAlwaysKeepsLastMessage(t *testing.T) { + // Budget smaller than a single message + mem := NewMemory("", 10) + + mem.Append(llm.ChatMessage{ + Role: llm.RoleUser, + Content: strings.Repeat("z", 50), + }) + + msgs := mem.Messages() + // Should keep at least the last message even if over budget + if len(msgs) < 1 { + t.Error("trim should always keep at least the last message") + } +} + +func TestTrimNeverOrphansToolResults(t *testing.T) { + // Use a small budget that will force trimming when the tool result is added. + // The sequence is: [user, assistant+tool_calls, tool_result] + // Trimming must not leave tool_result at the front without its assistant. + mem := NewMemory("", 200) + + mem.Append(llm.ChatMessage{ + Role: llm.RoleUser, + Content: "fetch data", + }) + mem.Append(llm.ChatMessage{ + Role: llm.RoleAssistant, + Content: "", + ToolCalls: []llm.ToolCall{ + {ID: "call_1", Type: "function", Function: llm.FunctionCall{Name: "http_request", Arguments: `{"url":"http://example.com"}`}}, + }, + }) + mem.Append(llm.ChatMessage{ + Role: llm.RoleTool, + Content: strings.Repeat("d", 300), // exceeds budget + ToolCallID: "call_1", + Name: "http_request", + }) + + msgs := mem.Messages() + // The front message must never be a tool result + if len(msgs) > 0 && msgs[0].Role == llm.RoleTool { + t.Error("trim left an orphaned tool result at the front of messages") + } +} + +func TestTrimKeepsAssistantToolPairWhenBudgetAllows(t *testing.T) { + // Budget large enough to hold assistant+tool_result but not user+assistant+tool + // This verifies we trim the user but keep the assistant→tool pair intact. + mem := NewMemory("", 500) + + mem.Append(llm.ChatMessage{ + Role: llm.RoleUser, + Content: strings.Repeat("u", 100), + }) + mem.Append(llm.ChatMessage{ + Role: llm.RoleAssistant, + Content: "", + ToolCalls: []llm.ToolCall{ + {ID: "call_1", Type: "function", Function: llm.FunctionCall{Name: "test", Arguments: `{}`}}, + }, + }) + mem.Append(llm.ChatMessage{ + Role: llm.RoleTool, + Content: strings.Repeat("r", 300), + ToolCallID: "call_1", + Name: "test", + }) + + msgs := mem.Messages() + // Should still have assistant and tool (maybe user trimmed) + hasAssistant := false + hasTool := false + for _, m := range msgs { + if m.Role == llm.RoleAssistant { + hasAssistant = true + } + if m.Role == llm.RoleTool { + hasTool = true + } + } + + if hasTool && !hasAssistant { + t.Error("tool result exists without its assistant message") + } +} + +func TestMemoryReset(t *testing.T) { + mem := NewMemory("system", 0) + + mem.Append(llm.ChatMessage{Role: llm.RoleUser, Content: "hi"}) + mem.Append(llm.ChatMessage{Role: llm.RoleAssistant, Content: "hello"}) + + mem.Reset() + + msgs := mem.Messages() + // Should only have the system prompt + if len(msgs) != 1 { + t.Errorf("expected 1 message (system) after reset, got %d", len(msgs)) + } + if msgs[0].Role != llm.RoleSystem { + t.Errorf("expected system message, got role %s", msgs[0].Role) + } +} diff --git a/forge-core/runtime/runtime.go b/forge-core/runtime/runtime.go new file mode 100644 index 0000000..97bdc61 --- /dev/null +++ b/forge-core/runtime/runtime.go @@ -0,0 +1,24 @@ +package runtime + +import ( + "context" + + "github.com/initializ/forge/forge-core/a2a" +) + +// AgentRuntime abstracts the agent execution backend. Implementations include +// SubprocessRuntime (real agent process) and MockRuntime (canned responses). +type AgentRuntime interface { + // Start launches the agent backend. + Start(ctx context.Context) error + // Invoke sends a synchronous task request and returns the completed task. + Invoke(ctx context.Context, taskID string, msg *a2a.Message) (*a2a.Task, error) + // Stream sends a streaming task request and returns a channel of task updates. + Stream(ctx context.Context, taskID string, msg *a2a.Message) (<-chan *a2a.Task, error) + // Healthy reports whether the agent backend is responsive. + Healthy(ctx context.Context) bool + // Stop shuts down the agent backend. + Stop() error + // Restart stops and restarts the agent backend. + Restart(ctx context.Context) error +} diff --git a/forge-core/schemas/agentspec.v1.0.schema.json b/forge-core/schemas/agentspec.v1.0.schema.json new file mode 100644 index 0000000..3e0e636 --- /dev/null +++ b/forge-core/schemas/agentspec.v1.0.schema.json @@ -0,0 +1,233 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://initializ.ai/schemas/agentspec.v1.0.schema.json", + "title": "AgentSpec", + "description": "Canonical schema for Initializ Forge agent specifications (v1.0)", + "type": "object", + "required": ["forge_version", "agent_id", "version", "name"], + "additionalProperties": false, + "properties": { + "forge_version": { + "type": "string", + "description": "Version of the Forge spec this agent targets" + }, + "agent_id": { + "type": "string", + "pattern": "^[a-z0-9-]+$", + "description": "Unique agent identifier (lowercase alphanumeric and hyphens)" + }, + "version": { + "type": "string", + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$", + "description": "Semantic version of the agent" + }, + "name": { + "type": "string", + "description": "Human-readable agent name" + }, + "description": { + "type": "string", + "description": "Brief description of the agent's purpose" + }, + "runtime": { + "type": "object", + "description": "Container runtime configuration", + "properties": { + "image": { + "type": "string", + "description": "Base container image" + }, + "entrypoint": { + "type": "array", + "items": { "type": "string" }, + "description": "Container entrypoint command" + }, + "port": { + "type": "integer", + "description": "Port the agent listens on" + }, + "env": { + "type": "object", + "additionalProperties": { "type": "string" }, + "description": "Environment variables" + } + }, + "additionalProperties": false + }, + "tools": { + "type": "array", + "description": "Tool definitions available to the agent", + "items": { + "type": "object", + "required": ["name"], + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "input_schema": { + "type": "object", + "description": "JSON Schema for tool input" + }, + "permissions": { + "type": "array", + "items": { "type": "string" } + }, + "forge_meta": { + "type": "object", + "properties": { + "allowed_tables": { + "type": "array", + "items": { "type": "string" } + }, + "allowed_endpoints": { + "type": "array", + "items": { "type": "string" } + }, + "network_scopes": { + "type": "array", + "items": { "type": "string" } + } + }, + "additionalProperties": false + }, + "category": { + "type": "string", + "description": "Tool category (builtin, adapter, dev, custom)" + }, + "skill_origin": { + "type": "string", + "description": "Skill ID this tool originated from" + } + }, + "additionalProperties": false + } + }, + "policy_scaffold": { + "type": "object", + "description": "Policy and guardrail configuration", + "properties": { + "guardrails": { + "type": "array", + "items": { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string" + }, + "config": { + "type": "object" + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + "identity": { + "type": "object", + "description": "Agent identity and authentication", + "properties": { + "issuer": { + "type": "string" + }, + "audience": { + "type": "string" + }, + "scopes": { + "type": "array", + "items": { "type": "string" } + } + }, + "additionalProperties": false + }, + "a2a": { + "type": "object", + "description": "A2A protocol configuration", + "properties": { + "endpoint": { + "type": "string" + }, + "skills": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { "type": "string" } + } + }, + "additionalProperties": false + } + }, + "capabilities": { + "type": "object", + "properties": { + "streaming": { "type": "boolean" }, + "push_notifications": { "type": "boolean" }, + "state_transition_history": { "type": "boolean" } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "tool_interface_version": { + "type": "string", + "description": "Version of the tool interface specification" + }, + "skills_spec_version": { + "type": "string", + "description": "Version of the skills specification" + }, + "forge_skills_ext_version": { + "type": "string", + "description": "Version of the Forge skills extension" + }, + "egress_profile": { + "type": "string", + "enum": ["strict", "standard", "permissive"], + "description": "Egress security profile" + }, + "egress_mode": { + "type": "string", + "enum": ["deny-all", "allowlist", "dev-open"], + "description": "Egress security mode" + }, + "model": { + "type": "object", + "description": "Model configuration for the agent", + "properties": { + "provider": { + "type": "string" + }, + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "parameters": { + "type": "object", + "additionalProperties": true + } + }, + "additionalProperties": false + } + } +} diff --git a/forge-core/schemas/embed.go b/forge-core/schemas/embed.go new file mode 100644 index 0000000..99ac10b --- /dev/null +++ b/forge-core/schemas/embed.go @@ -0,0 +1,6 @@ +package schemas + +import _ "embed" + +//go:embed agentspec.v1.0.schema.json +var AgentSpecV1Schema []byte diff --git a/forge-core/security/allowlist.go b/forge-core/security/allowlist.go new file mode 100644 index 0000000..79515af --- /dev/null +++ b/forge-core/security/allowlist.go @@ -0,0 +1,34 @@ +package security + +import "encoding/json" + +// allowlistOutput is the JSON structure for egress_allowlist.json. +type allowlistOutput struct { + Profile string `json:"profile"` + Mode string `json:"mode"` + AllowedDomains []string `json:"allowed_domains"` + ToolDomains []string `json:"tool_domains"` + AllDomains []string `json:"all_domains"` +} + +// GenerateAllowlistJSON produces the JSON output for egress_allowlist.json. +func GenerateAllowlistJSON(cfg *EgressConfig) ([]byte, error) { + out := allowlistOutput{ + Profile: string(cfg.Profile), + Mode: string(cfg.Mode), + AllowedDomains: cfg.AllowedDomains, + ToolDomains: cfg.ToolDomains, + AllDomains: cfg.AllDomains, + } + // Ensure empty arrays instead of null in JSON + if out.AllowedDomains == nil { + out.AllowedDomains = []string{} + } + if out.ToolDomains == nil { + out.ToolDomains = []string{} + } + if out.AllDomains == nil { + out.AllDomains = []string{} + } + return json.MarshalIndent(out, "", " ") +} diff --git a/forge-core/security/allowlist_test.go b/forge-core/security/allowlist_test.go new file mode 100644 index 0000000..94d9e7d --- /dev/null +++ b/forge-core/security/allowlist_test.go @@ -0,0 +1,157 @@ +package security + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestGenerateAllowlistJSON_DenyAll(t *testing.T) { + cfg := &EgressConfig{ + Profile: ProfileStrict, + Mode: ModeDenyAll, + } + data, err := GenerateAllowlistJSON(cfg) + if err != nil { + t.Fatalf("GenerateAllowlistJSON: %v", err) + } + + var out map[string]any + if err := json.Unmarshal(data, &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if out["profile"] != "strict" { + t.Errorf("profile = %v, want strict", out["profile"]) + } + if out["mode"] != "deny-all" { + t.Errorf("mode = %v, want deny-all", out["mode"]) + } + // Should have empty arrays, not null + if domains, ok := out["all_domains"].([]any); !ok || len(domains) != 0 { + t.Errorf("all_domains should be empty array, got %v", out["all_domains"]) + } +} + +func TestGenerateAllowlistJSON_Allowlist(t *testing.T) { + cfg := &EgressConfig{ + Profile: ProfileStandard, + Mode: ModeAllowlist, + AllowedDomains: []string{"api.example.com"}, + ToolDomains: []string{"googleapis.com"}, + AllDomains: []string{"api.example.com", "googleapis.com"}, + } + data, err := GenerateAllowlistJSON(cfg) + if err != nil { + t.Fatalf("GenerateAllowlistJSON: %v", err) + } + + var out map[string]any + if err := json.Unmarshal(data, &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + domains := out["all_domains"].([]any) + if len(domains) != 2 { + t.Errorf("all_domains count = %d, want 2", len(domains)) + } +} + +func TestGenerateK8sNetworkPolicy_DenyAll(t *testing.T) { + cfg := &EgressConfig{ + Profile: ProfileStrict, + Mode: ModeDenyAll, + } + data, err := GenerateK8sNetworkPolicy("test-agent", cfg) + if err != nil { + t.Fatalf("GenerateK8sNetworkPolicy: %v", err) + } + s := string(data) + if len(s) == 0 { + t.Fatal("expected non-empty output") + } +} + +func TestGenerateK8sNetworkPolicy_Nil(t *testing.T) { + _, err := GenerateK8sNetworkPolicy("test-agent", nil) + if err == nil { + t.Fatal("expected error for nil config") + } +} + +func TestGenerateK8sNetworkPolicy_Allowlist(t *testing.T) { + cfg := &EgressConfig{ + Profile: ProfileStandard, + Mode: ModeAllowlist, + AllDomains: []string{"api.example.com", "hooks.slack.com"}, + } + data, err := GenerateK8sNetworkPolicy("my-agent", cfg) + if err != nil { + t.Fatalf("GenerateK8sNetworkPolicy: %v", err) + } + + s := string(data) + // Should contain pod selector + if !strings.Contains(s, "app: my-agent") { + t.Error("expected pod selector with agent ID") + } + // Should contain port 443 + if !strings.Contains(s, "443") { + t.Error("expected port 443 in egress rules") + } + // Should contain domain annotation + if !strings.Contains(s, "api.example.com") { + t.Error("expected domain annotation") + } +} + +func TestGenerateK8sNetworkPolicy_DevOpen(t *testing.T) { + cfg := &EgressConfig{ + Profile: ProfilePermissive, + Mode: ModeDevOpen, + } + data, err := GenerateK8sNetworkPolicy("dev-agent", cfg) + if err != nil { + t.Fatalf("GenerateK8sNetworkPolicy: %v", err) + } + + s := string(data) + // Dev-open should allow egress (not deny) + if strings.Contains(s, "egress: []") { + t.Error("dev-open should not deny all egress") + } + // Should have ports 80 and 443 + if !strings.Contains(s, "80") || !strings.Contains(s, "443") { + t.Error("dev-open should allow ports 80 and 443") + } +} + +func TestGenerateAllowlistJSON_NilArraysSafeJSON(t *testing.T) { + cfg := &EgressConfig{ + Profile: ProfileStandard, + Mode: ModeAllowlist, + // All slices intentionally nil + } + data, err := GenerateAllowlistJSON(cfg) + if err != nil { + t.Fatalf("GenerateAllowlistJSON: %v", err) + } + + s := string(data) + // Should have empty arrays, not null + if strings.Contains(s, "null") { + t.Errorf("JSON should not contain null for empty slices: %s", s) + } + // Verify JSON parses correctly + var out map[string]any + if err := json.Unmarshal(data, &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + // Each domain field should be an empty array + for _, field := range []string{"allowed_domains", "tool_domains", "all_domains"} { + arr, ok := out[field].([]any) + if !ok { + t.Errorf("%s should be an array, got %T", field, out[field]) + } else if len(arr) != 0 { + t.Errorf("%s should be empty, got %d items", field, len(arr)) + } + } +} diff --git a/forge-core/security/capabilities.go b/forge-core/security/capabilities.go new file mode 100644 index 0000000..3367856 --- /dev/null +++ b/forge-core/security/capabilities.go @@ -0,0 +1,22 @@ +package security + +// DefaultCapabilityBundles maps capability names to their required domain sets. +var DefaultCapabilityBundles = map[string][]string{ + "slack": {"slack.com", "hooks.slack.com", "api.slack.com"}, + "telegram": {"api.telegram.org"}, +} + +// ResolveCapabilities returns a deduplicated list of domains for the given capability names. +func ResolveCapabilities(capabilities []string) []string { + seen := make(map[string]bool) + var domains []string + for _, cap := range capabilities { + for _, d := range DefaultCapabilityBundles[cap] { + if !seen[d] { + seen[d] = true + domains = append(domains, d) + } + } + } + return domains +} diff --git a/forge-core/security/capabilities_test.go b/forge-core/security/capabilities_test.go new file mode 100644 index 0000000..46d9df7 --- /dev/null +++ b/forge-core/security/capabilities_test.go @@ -0,0 +1,54 @@ +package security + +import ( + "testing" +) + +func TestResolveCapabilities_Slack(t *testing.T) { + domains := ResolveCapabilities([]string{"slack"}) + expected := map[string]bool{ + "slack.com": true, + "hooks.slack.com": true, + "api.slack.com": true, + } + if len(domains) != len(expected) { + t.Fatalf("got %d domains, want %d: %v", len(domains), len(expected), domains) + } + for _, d := range domains { + if !expected[d] { + t.Errorf("unexpected domain %q", d) + } + } +} + +func TestResolveCapabilities_Telegram(t *testing.T) { + domains := ResolveCapabilities([]string{"telegram"}) + if len(domains) != 1 { + t.Fatalf("got %d domains, want 1: %v", len(domains), domains) + } + if domains[0] != "api.telegram.org" { + t.Errorf("got %q, want %q", domains[0], "api.telegram.org") + } +} + +func TestResolveCapabilities_Unknown(t *testing.T) { + domains := ResolveCapabilities([]string{"discord"}) + if len(domains) != 0 { + t.Errorf("expected empty for unknown capability, got %v", domains) + } +} + +func TestResolveCapabilities_Dedup(t *testing.T) { + domains := ResolveCapabilities([]string{"slack", "slack"}) + // Should deduplicate: slack.com, hooks.slack.com, api.slack.com + if len(domains) != 3 { + t.Errorf("got %d domains after dedup, want 3: %v", len(domains), domains) + } +} + +func TestResolveCapabilities_Nil(t *testing.T) { + domains := ResolveCapabilities(nil) + if len(domains) != 0 { + t.Errorf("expected empty for nil input, got %v", domains) + } +} diff --git a/forge-core/security/network_policy.go b/forge-core/security/network_policy.go new file mode 100644 index 0000000..d37bcc4 --- /dev/null +++ b/forge-core/security/network_policy.go @@ -0,0 +1,77 @@ +package security + +import ( + "bytes" + "fmt" + "strings" + "text/template" +) + +const networkPolicyTemplate = `apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{.AgentID}}-network + labels: + app: {{.AgentID}} + {{- if .Annotation}} + annotations: + ai.initializ.forge/allowed-domains: "{{.Annotation}}" + {{- end}} +spec: + podSelector: + matchLabels: + app: {{.AgentID}} + policyTypes: + - Egress + {{- if .DenyAll}} + egress: [] + {{- else}} + egress: + - to: [] + ports: + - protocol: TCP + port: 443 + - protocol: TCP + port: 80 + {{- end}}` + +type networkPolicyTemplateData struct { + AgentID string + DenyAll bool + Annotation string +} + +// GenerateK8sNetworkPolicy produces a K8s NetworkPolicy YAML for the given agent and egress config. +func GenerateK8sNetworkPolicy(agentID string, cfg *EgressConfig) ([]byte, error) { + if cfg == nil { + return nil, fmt.Errorf("egress config is nil") + } + + data := networkPolicyTemplateData{ + AgentID: agentID, + } + + switch cfg.Mode { + case ModeDenyAll: + data.DenyAll = true + case ModeDevOpen: + data.DenyAll = false + case ModeAllowlist: + data.DenyAll = false + if len(cfg.AllDomains) > 0 { + data.Annotation = strings.Join(cfg.AllDomains, ",") + } + } + + tmpl, err := template.New("network-policy").Parse(networkPolicyTemplate) + if err != nil { + return nil, fmt.Errorf("parsing network policy template: %w", err) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return nil, fmt.Errorf("rendering network policy: %w", err) + } + + return buf.Bytes(), nil +} diff --git a/forge-core/security/resolver.go b/forge-core/security/resolver.go new file mode 100644 index 0000000..03ae287 --- /dev/null +++ b/forge-core/security/resolver.go @@ -0,0 +1,87 @@ +package security + +import ( + "fmt" + "sort" +) + +// DefaultProfile returns the default egress profile. +func DefaultProfile() EgressProfile { return ProfileStrict } + +// DefaultMode returns the default egress mode. +func DefaultMode() EgressMode { return ModeDenyAll } + +// Resolve builds an EgressConfig from profile, mode, explicit domains, tool names, and capabilities. +func Resolve(profile, mode string, explicitDomains, toolNames, capabilities []string) (*EgressConfig, error) { + p := EgressProfile(profile) + if p == "" { + p = DefaultProfile() + } + if err := validateProfile(p); err != nil { + return nil, err + } + + m := EgressMode(mode) + if m == "" { + m = DefaultMode() + } + if err := validateMode(m); err != nil { + return nil, err + } + + cfg := &EgressConfig{ + Profile: p, + Mode: m, + } + + switch m { + case ModeDenyAll: + // No domains allowed + return cfg, nil + case ModeDevOpen: + // No restrictions + return cfg, nil + case ModeAllowlist: + cfg.AllowedDomains = explicitDomains + cfg.ToolDomains = InferToolDomains(toolNames) + capDomains := ResolveCapabilities(capabilities) + all := append([]string{}, explicitDomains...) + all = append(all, cfg.ToolDomains...) + all = append(all, capDomains...) + cfg.AllDomains = dedup(all) + return cfg, nil + } + + return cfg, nil +} + +func validateProfile(p EgressProfile) error { + switch p { + case ProfileStrict, ProfileStandard, ProfilePermissive: + return nil + default: + return fmt.Errorf("invalid egress profile %q: must be strict, standard, or permissive", p) + } +} + +func validateMode(m EgressMode) error { + switch m { + case ModeDenyAll, ModeAllowlist, ModeDevOpen: + return nil + default: + return fmt.Errorf("invalid egress mode %q: must be deny-all, allowlist, or dev-open", m) + } +} + +func dedup(items []string) []string { + seen := make(map[string]bool, len(items)) + result := make([]string, 0, len(items)) + for _, item := range items { + if item != "" && !seen[item] { + seen[item] = true + result = append(result, item) + } + } + sort.Strings(result) + return result +} diff --git a/forge-core/security/resolver_test.go b/forge-core/security/resolver_test.go new file mode 100644 index 0000000..38268c0 --- /dev/null +++ b/forge-core/security/resolver_test.go @@ -0,0 +1,170 @@ +package security + +import ( + "testing" +) + +func TestResolve_DenyAll(t *testing.T) { + cfg, err := Resolve("strict", "deny-all", nil, nil, nil) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if cfg.Profile != ProfileStrict { + t.Errorf("Profile = %q, want %q", cfg.Profile, ProfileStrict) + } + if cfg.Mode != ModeDenyAll { + t.Errorf("Mode = %q, want %q", cfg.Mode, ModeDenyAll) + } + if len(cfg.AllDomains) != 0 { + t.Errorf("AllDomains should be empty, got %v", cfg.AllDomains) + } +} + +func TestResolve_DevOpen(t *testing.T) { + cfg, err := Resolve("permissive", "dev-open", nil, nil, nil) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if cfg.Mode != ModeDevOpen { + t.Errorf("Mode = %q, want %q", cfg.Mode, ModeDevOpen) + } +} + +func TestResolve_Allowlist(t *testing.T) { + explicit := []string{"api.example.com", "data.example.com"} + tools := []string{"web_search", "github_api"} + + cfg, err := Resolve("standard", "allowlist", explicit, tools, nil) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if cfg.Mode != ModeAllowlist { + t.Errorf("Mode = %q, want %q", cfg.Mode, ModeAllowlist) + } + if len(cfg.AllowedDomains) != 2 { + t.Errorf("AllowedDomains count = %d, want 2", len(cfg.AllowedDomains)) + } + if len(cfg.ToolDomains) == 0 { + t.Error("ToolDomains should not be empty for web_search + github_api") + } + if len(cfg.AllDomains) == 0 { + t.Error("AllDomains should not be empty") + } + + // Check deduplication + seen := make(map[string]bool) + for _, d := range cfg.AllDomains { + if seen[d] { + t.Errorf("duplicate domain in AllDomains: %s", d) + } + seen[d] = true + } +} + +func TestResolve_Defaults(t *testing.T) { + cfg, err := Resolve("", "", nil, nil, nil) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if cfg.Profile != ProfileStrict { + t.Errorf("Profile = %q, want default %q", cfg.Profile, ProfileStrict) + } + if cfg.Mode != ModeDenyAll { + t.Errorf("Mode = %q, want default %q", cfg.Mode, ModeDenyAll) + } +} + +func TestResolve_InvalidProfile(t *testing.T) { + _, err := Resolve("invalid", "deny-all", nil, nil, nil) + if err == nil { + t.Fatal("expected error for invalid profile") + } +} + +func TestResolve_InvalidMode(t *testing.T) { + _, err := Resolve("strict", "invalid", nil, nil, nil) + if err == nil { + t.Fatal("expected error for invalid mode") + } +} + +func TestInferToolDomains(t *testing.T) { + domains := InferToolDomains([]string{"web_search", "github_api"}) + if len(domains) == 0 { + t.Fatal("expected inferred domains") + } + // Check web_search domains included + found := false + for _, d := range domains { + if d == "api.perplexity.ai" { + found = true + break + } + } + if !found { + t.Error("expected api.perplexity.ai in inferred domains for web_search") + } +} + +func TestInferToolDomains_Unknown(t *testing.T) { + domains := InferToolDomains([]string{"unknown_tool"}) + if len(domains) != 0 { + t.Errorf("expected no domains for unknown tool, got %v", domains) + } +} + +func TestResolve_AllowlistWithCapabilities(t *testing.T) { + explicit := []string{"api.example.com"} + cfg, err := Resolve("standard", "allowlist", explicit, nil, []string{"slack"}) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + + // Check that slack domains are in AllDomains + want := map[string]bool{ + "api.example.com": true, + "slack.com": true, + "hooks.slack.com": true, + "api.slack.com": true, + } + for d := range want { + found := false + for _, got := range cfg.AllDomains { + if got == d { + found = true + break + } + } + if !found { + t.Errorf("expected %q in AllDomains, got %v", d, cfg.AllDomains) + } + } +} + +func TestResolve_AllowlistTelegramCapability(t *testing.T) { + cfg, err := Resolve("standard", "allowlist", nil, nil, []string{"telegram"}) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + + found := false + for _, d := range cfg.AllDomains { + if d == "api.telegram.org" { + found = true + break + } + } + if !found { + t.Errorf("expected api.telegram.org in AllDomains, got %v", cfg.AllDomains) + } +} + +func TestResolve_CapabilitiesIgnoredForDenyAll(t *testing.T) { + cfg, err := Resolve("strict", "deny-all", nil, nil, []string{"slack"}) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if len(cfg.AllDomains) != 0 { + t.Errorf("AllDomains should be empty for deny-all, got %v", cfg.AllDomains) + } +} diff --git a/forge-core/security/tool_domains.go b/forge-core/security/tool_domains.go new file mode 100644 index 0000000..db65bd6 --- /dev/null +++ b/forge-core/security/tool_domains.go @@ -0,0 +1,33 @@ +package security + +// DefaultToolDomains maps tool names to their known required domains. +var DefaultToolDomains = map[string][]string{ + "web_search": {"api.perplexity.ai"}, + "web-search": {"api.perplexity.ai"}, + "http_request": {}, // dynamic — depends on user config + "slack_notify": {"slack.com", "hooks.slack.com"}, + "github_api": {"api.github.com", "github.com"}, + "openai_completion": {"api.openai.com"}, + "anthropic_api": {"api.anthropic.com"}, + "huggingface_api": {"api-inference.huggingface.co", "huggingface.co"}, + "google_vertex": {"us-central1-aiplatform.googleapis.com"}, + "sendgrid_email": {"api.sendgrid.com"}, + "twilio_sms": {"api.twilio.com"}, + "aws_bedrock": {"bedrock-runtime.us-east-1.amazonaws.com"}, + "azure_openai": {"openai.azure.com"}, +} + +// InferToolDomains looks up known domains for the given tool names and returns a deduplicated list. +func InferToolDomains(toolNames []string) []string { + seen := make(map[string]bool) + var domains []string + for _, name := range toolNames { + for _, d := range DefaultToolDomains[name] { + if !seen[d] { + seen[d] = true + domains = append(domains, d) + } + } + } + return domains +} diff --git a/forge-core/security/types.go b/forge-core/security/types.go new file mode 100644 index 0000000..ccfe928 --- /dev/null +++ b/forge-core/security/types.go @@ -0,0 +1,29 @@ +// Package security provides egress security resolution for containerized agents. +package security + +// EgressProfile controls the overall security posture. +type EgressProfile string + +const ( + ProfileStrict EgressProfile = "strict" + ProfileStandard EgressProfile = "standard" + ProfilePermissive EgressProfile = "permissive" +) + +// EgressMode controls egress behavior. +type EgressMode string + +const ( + ModeDenyAll EgressMode = "deny-all" + ModeAllowlist EgressMode = "allowlist" + ModeDevOpen EgressMode = "dev-open" +) + +// EgressConfig holds the resolved egress configuration. +type EgressConfig struct { + Profile EgressProfile `json:"profile"` + Mode EgressMode `json:"mode"` + AllowedDomains []string `json:"allowed_domains,omitempty"` // explicit user domains + ToolDomains []string `json:"tool_domains,omitempty"` // inferred from tools + AllDomains []string `json:"all_domains,omitempty"` // deduplicated union +} diff --git a/forge-core/skills/compiler.go b/forge-core/skills/compiler.go new file mode 100644 index 0000000..4ab4541 --- /dev/null +++ b/forge-core/skills/compiler.go @@ -0,0 +1,60 @@ +package skills + +import ( + "fmt" + "strings" +) + +// CompiledSkills holds the result of compiling skill entries. +type CompiledSkills struct { + Skills []CompiledSkill `json:"skills"` + Count int `json:"count"` + Version string `json:"version"` + Prompt string `json:"-"` // written separately as prompt.txt +} + +// CompiledSkill represents a single compiled skill. +type CompiledSkill struct { + Name string `json:"name"` + Description string `json:"description"` + InputSpec string `json:"input_spec,omitempty"` + OutputSpec string `json:"output_spec,omitempty"` +} + +// Compile converts parsed SkillEntry values into CompiledSkills. +func Compile(entries []SkillEntry) (*CompiledSkills, error) { + cs := &CompiledSkills{ + Skills: make([]CompiledSkill, 0, len(entries)), + Version: "agentskills-v1", + } + + var promptBuilder strings.Builder + promptBuilder.WriteString("# Available Skills\n\n") + + for _, e := range entries { + skill := CompiledSkill{ + Name: e.Name, + Description: e.Description, + InputSpec: e.InputSpec, + OutputSpec: e.OutputSpec, + } + cs.Skills = append(cs.Skills, skill) + + // Build prompt catalog entry + fmt.Fprintf(&promptBuilder, "## %s\n", e.Name) + if e.Description != "" { + fmt.Fprintf(&promptBuilder, "%s\n", e.Description) + } + if e.InputSpec != "" { + fmt.Fprintf(&promptBuilder, "Input: %s\n", e.InputSpec) + } + if e.OutputSpec != "" { + fmt.Fprintf(&promptBuilder, "Output: %s\n", e.OutputSpec) + } + promptBuilder.WriteString("\n") + } + + cs.Count = len(cs.Skills) + cs.Prompt = promptBuilder.String() + return cs, nil +} diff --git a/forge-core/skills/compiler_test.go b/forge-core/skills/compiler_test.go new file mode 100644 index 0000000..6509ffd --- /dev/null +++ b/forge-core/skills/compiler_test.go @@ -0,0 +1,127 @@ +package skills + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestCompile_Empty(t *testing.T) { + cs, err := Compile(nil) + if err != nil { + t.Fatalf("Compile: %v", err) + } + if cs.Count != 0 { + t.Errorf("Count = %d, want 0", cs.Count) + } + if len(cs.Skills) != 0 { + t.Errorf("Skills length = %d, want 0", len(cs.Skills)) + } + if cs.Version != "agentskills-v1" { + t.Errorf("Version = %q, want %q", cs.Version, "agentskills-v1") + } +} + +func TestCompile_MultipleSkills(t *testing.T) { + entries := []SkillEntry{ + {Name: "web_search", Description: "Search the web", InputSpec: "query: string", OutputSpec: "results: []string"}, + {Name: "summarize", Description: "Summarize text", InputSpec: "text: string"}, + } + + cs, err := Compile(entries) + if err != nil { + t.Fatalf("Compile: %v", err) + } + if cs.Count != 2 { + t.Errorf("Count = %d, want 2", cs.Count) + } + if cs.Skills[0].Name != "web_search" { + t.Errorf("Skills[0].Name = %q, want %q", cs.Skills[0].Name, "web_search") + } + if cs.Skills[1].InputSpec != "text: string" { + t.Errorf("Skills[1].InputSpec = %q, want %q", cs.Skills[1].InputSpec, "text: string") + } + + // Check JSON serialization + data, err := json.Marshal(cs) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if raw["count"].(float64) != 2 { + t.Errorf("JSON count = %v, want 2", raw["count"]) + } + + // Check prompt is non-empty + if cs.Prompt == "" { + t.Error("Prompt should not be empty") + } +} + +func TestCompile_SingleSkill(t *testing.T) { + entries := []SkillEntry{ + {Name: "translate", Description: "Translate text between languages", InputSpec: "text: string, target_lang: string", OutputSpec: "translated: string"}, + } + + cs, err := Compile(entries) + if err != nil { + t.Fatalf("Compile: %v", err) + } + if cs.Count != 1 { + t.Errorf("Count = %d, want 1", cs.Count) + } + if cs.Version != "agentskills-v1" { + t.Errorf("Version = %q, want %q", cs.Version, "agentskills-v1") + } + if cs.Prompt == "" { + t.Error("Prompt should not be empty for a single skill") + } + if cs.Skills[0].Name != "translate" { + t.Errorf("Skills[0].Name = %q, want %q", cs.Skills[0].Name, "translate") + } + if cs.Skills[0].OutputSpec != "translated: string" { + t.Errorf("Skills[0].OutputSpec = %q", cs.Skills[0].OutputSpec) + } +} + +func TestCompile_PromptContainsNames(t *testing.T) { + entries := []SkillEntry{ + {Name: "web_search", Description: "Search the internet"}, + {Name: "summarize", Description: "Summarize long text"}, + } + + cs, err := Compile(entries) + if err != nil { + t.Fatalf("Compile: %v", err) + } + + if !strings.Contains(cs.Prompt, "web_search") { + t.Error("Prompt should contain skill name 'web_search'") + } + if !strings.Contains(cs.Prompt, "summarize") { + t.Error("Prompt should contain skill name 'summarize'") + } + if !strings.Contains(cs.Prompt, "Search the internet") { + t.Error("Prompt should contain skill description") + } +} + +func TestCompile_EmptyDescription(t *testing.T) { + entries := []SkillEntry{ + {Name: "no_desc_skill", Description: ""}, + } + + cs, err := Compile(entries) + if err != nil { + t.Fatalf("Compile: %v", err) + } + if cs.Count != 1 { + t.Errorf("Count = %d, want 1", cs.Count) + } + if cs.Skills[0].Description != "" { + t.Errorf("Description should be empty, got %q", cs.Skills[0].Description) + } +} diff --git a/forge-core/skills/derive.go b/forge-core/skills/derive.go new file mode 100644 index 0000000..fd97143 --- /dev/null +++ b/forge-core/skills/derive.go @@ -0,0 +1,72 @@ +package skills + +import "sort" + +// DerivedCLIConfig holds auto-derived cli_execute configuration from skill requirements. +type DerivedCLIConfig struct { + AllowedBinaries []string + EnvPassthrough []string +} + +// DeriveCLIConfig produces cli_execute configuration from aggregated requirements. +// AllowedBinaries = reqs.Bins, EnvPassthrough = union of all env vars. +func DeriveCLIConfig(reqs *AggregatedRequirements) *DerivedCLIConfig { + if reqs == nil { + return &DerivedCLIConfig{} + } + + envSet := make(map[string]bool) + for _, v := range reqs.EnvRequired { + envSet[v] = true + } + for _, group := range reqs.EnvOneOf { + for _, v := range group { + envSet[v] = true + } + } + for _, v := range reqs.EnvOptional { + envSet[v] = true + } + + var envPass []string + if len(envSet) > 0 { + envPass = make([]string, 0, len(envSet)) + for k := range envSet { + envPass = append(envPass, k) + } + sort.Strings(envPass) + } + + return &DerivedCLIConfig{ + AllowedBinaries: reqs.Bins, // already sorted from AggregateRequirements + EnvPassthrough: envPass, + } +} + +// MergeCLIConfig merges derived config with explicit forge.yaml config. +// Explicit non-nil slices override derived values entirely. +// Nil/empty explicit slices allow derived values through. +func MergeCLIConfig(explicit, derived *DerivedCLIConfig) *DerivedCLIConfig { + if derived == nil { + return explicit + } + if explicit == nil { + return derived + } + + merged := &DerivedCLIConfig{} + + if len(explicit.AllowedBinaries) > 0 { + merged.AllowedBinaries = explicit.AllowedBinaries + } else { + merged.AllowedBinaries = derived.AllowedBinaries + } + + if len(explicit.EnvPassthrough) > 0 { + merged.EnvPassthrough = explicit.EnvPassthrough + } else { + merged.EnvPassthrough = derived.EnvPassthrough + } + + return merged +} diff --git a/forge-core/skills/derive_test.go b/forge-core/skills/derive_test.go new file mode 100644 index 0000000..024cb0e --- /dev/null +++ b/forge-core/skills/derive_test.go @@ -0,0 +1,70 @@ +package skills + +import ( + "testing" +) + +func TestDerive_Basic(t *testing.T) { + reqs := &AggregatedRequirements{ + Bins: []string{"curl", "jq"}, + EnvRequired: []string{"API_KEY"}, + EnvOneOf: [][]string{{"OPENAI_KEY", "ANTHROPIC_KEY"}}, + EnvOptional: []string{"DEBUG"}, + } + + cfg := DeriveCLIConfig(reqs) + + if len(cfg.AllowedBinaries) != 2 { + t.Errorf("AllowedBinaries = %v, want 2 items", cfg.AllowedBinaries) + } + if cfg.AllowedBinaries[0] != "curl" || cfg.AllowedBinaries[1] != "jq" { + t.Errorf("AllowedBinaries = %v, want [curl jq]", cfg.AllowedBinaries) + } + + // EnvPassthrough should be union of all env vars, sorted + // API_KEY, ANTHROPIC_KEY, DEBUG, OPENAI_KEY + if len(cfg.EnvPassthrough) != 4 { + t.Fatalf("EnvPassthrough = %v, want 4 items", cfg.EnvPassthrough) + } + expected := []string{"ANTHROPIC_KEY", "API_KEY", "DEBUG", "OPENAI_KEY"} + for i, v := range expected { + if cfg.EnvPassthrough[i] != v { + t.Errorf("EnvPassthrough[%d] = %q, want %q", i, cfg.EnvPassthrough[i], v) + } + } +} + +func TestMerge_ExplicitOverrides(t *testing.T) { + explicit := &DerivedCLIConfig{ + AllowedBinaries: []string{"python"}, + EnvPassthrough: []string{"CUSTOM_VAR"}, + } + derived := &DerivedCLIConfig{ + AllowedBinaries: []string{"curl", "jq"}, + EnvPassthrough: []string{"API_KEY"}, + } + + merged := MergeCLIConfig(explicit, derived) + if len(merged.AllowedBinaries) != 1 || merged.AllowedBinaries[0] != "python" { + t.Errorf("AllowedBinaries = %v, want [python]", merged.AllowedBinaries) + } + if len(merged.EnvPassthrough) != 1 || merged.EnvPassthrough[0] != "CUSTOM_VAR" { + t.Errorf("EnvPassthrough = %v, want [CUSTOM_VAR]", merged.EnvPassthrough) + } +} + +func TestMerge_NilAllowsDerived(t *testing.T) { + explicit := &DerivedCLIConfig{} // empty slices (nil) + derived := &DerivedCLIConfig{ + AllowedBinaries: []string{"curl", "jq"}, + EnvPassthrough: []string{"API_KEY"}, + } + + merged := MergeCLIConfig(explicit, derived) + if len(merged.AllowedBinaries) != 2 { + t.Errorf("AllowedBinaries = %v, want [curl jq]", merged.AllowedBinaries) + } + if len(merged.EnvPassthrough) != 1 || merged.EnvPassthrough[0] != "API_KEY" { + t.Errorf("EnvPassthrough = %v, want [API_KEY]", merged.EnvPassthrough) + } +} diff --git a/forge-core/skills/env_resolver.go b/forge-core/skills/env_resolver.go new file mode 100644 index 0000000..d3135ca --- /dev/null +++ b/forge-core/skills/env_resolver.go @@ -0,0 +1,137 @@ +package skills + +import ( + "fmt" + "os/exec" +) + +// EnvSource describes where an environment variable was found. +type EnvSource string + +const ( + EnvSourceOS EnvSource = "environment" + EnvSourceDotEnv EnvSource = "dotenv" + EnvSourceConfig EnvSource = "config" + EnvSourceMissing EnvSource = "missing" +) + +// ValidationDiagnostic represents a single validation finding. +type ValidationDiagnostic struct { + Level string // "error", "warning", "info" + Message string + Var string +} + +// EnvResolver checks env var availability across multiple sources. +type EnvResolver struct { + osEnv map[string]string + dotEnv map[string]string + cfgEnv map[string]string +} + +// NewEnvResolver creates an EnvResolver with the given env sources. +func NewEnvResolver(osEnv, dotEnv, cfgEnv map[string]string) *EnvResolver { + if osEnv == nil { + osEnv = map[string]string{} + } + if dotEnv == nil { + dotEnv = map[string]string{} + } + if cfgEnv == nil { + cfgEnv = map[string]string{} + } + return &EnvResolver{osEnv: osEnv, dotEnv: dotEnv, cfgEnv: cfgEnv} +} + +// Resolve checks all requirements against available env sources. +// Returns diagnostics: error for missing required/one_of, warning for missing optional. +func (r *EnvResolver) Resolve(reqs *AggregatedRequirements) []ValidationDiagnostic { + if reqs == nil { + return nil + } + var diags []ValidationDiagnostic + + // Check required vars + for _, v := range reqs.EnvRequired { + src := r.lookup(v) + if src == EnvSourceMissing { + diags = append(diags, ValidationDiagnostic{ + Level: "error", + Message: fmt.Sprintf("required env var %s is not set", v), + Var: v, + }) + } + } + + // Check one_of groups + for _, group := range reqs.EnvOneOf { + found := false + for _, v := range group { + if r.lookup(v) != EnvSourceMissing { + found = true + break + } + } + if !found { + diags = append(diags, ValidationDiagnostic{ + Level: "error", + Message: fmt.Sprintf("at least one of [%s] must be set", joinVars(group)), + Var: group[0], + }) + } + } + + // Check optional vars + for _, v := range reqs.EnvOptional { + src := r.lookup(v) + if src == EnvSourceMissing { + diags = append(diags, ValidationDiagnostic{ + Level: "warning", + Message: fmt.Sprintf("optional env var %s is not set", v), + Var: v, + }) + } + } + + return diags +} + +// lookup checks for a var across all sources in priority order. +func (r *EnvResolver) lookup(key string) EnvSource { + if _, ok := r.osEnv[key]; ok { + return EnvSourceOS + } + if _, ok := r.dotEnv[key]; ok { + return EnvSourceDotEnv + } + if _, ok := r.cfgEnv[key]; ok { + return EnvSourceConfig + } + return EnvSourceMissing +} + +// BinDiagnostics checks binary availability via exec.LookPath. +func BinDiagnostics(bins []string) []ValidationDiagnostic { + var diags []ValidationDiagnostic + for _, bin := range bins { + if _, err := exec.LookPath(bin); err != nil { + diags = append(diags, ValidationDiagnostic{ + Level: "warning", + Message: fmt.Sprintf("binary %q not found in PATH", bin), + Var: bin, + }) + } + } + return diags +} + +func joinVars(vars []string) string { + result := "" + for i, v := range vars { + if i > 0 { + result += ", " + } + result += v + } + return result +} diff --git a/forge-core/skills/env_resolver_test.go b/forge-core/skills/env_resolver_test.go new file mode 100644 index 0000000..af61eaa --- /dev/null +++ b/forge-core/skills/env_resolver_test.go @@ -0,0 +1,101 @@ +package skills + +import ( + "testing" +) + +func TestResolve_AllFromOS(t *testing.T) { + osEnv := map[string]string{ + "API_KEY": "key123", + "TIMEOUT": "30", + } + resolver := NewEnvResolver(osEnv, nil, nil) + reqs := &AggregatedRequirements{ + EnvRequired: []string{"API_KEY"}, + EnvOptional: []string{"TIMEOUT"}, + } + + diags := resolver.Resolve(reqs) + if len(diags) != 0 { + t.Errorf("expected 0 diagnostics, got %d: %+v", len(diags), diags) + } +} + +func TestResolve_FallbackToDotEnv(t *testing.T) { + osEnv := map[string]string{} + dotEnv := map[string]string{ + "API_KEY": "from-dotenv", + } + resolver := NewEnvResolver(osEnv, dotEnv, nil) + reqs := &AggregatedRequirements{ + EnvRequired: []string{"API_KEY"}, + } + + diags := resolver.Resolve(reqs) + if len(diags) != 0 { + t.Errorf("expected 0 diagnostics, got %d: %+v", len(diags), diags) + } +} + +func TestResolve_MissingRequired_Error(t *testing.T) { + resolver := NewEnvResolver(nil, nil, nil) + reqs := &AggregatedRequirements{ + EnvRequired: []string{"API_KEY"}, + } + + diags := resolver.Resolve(reqs) + if len(diags) != 1 { + t.Fatalf("expected 1 diagnostic, got %d", len(diags)) + } + if diags[0].Level != "error" { + t.Errorf("diagnostic level = %q, want error", diags[0].Level) + } + if diags[0].Var != "API_KEY" { + t.Errorf("diagnostic var = %q, want API_KEY", diags[0].Var) + } +} + +func TestResolve_MissingOneOf_Error(t *testing.T) { + resolver := NewEnvResolver(nil, nil, nil) + reqs := &AggregatedRequirements{ + EnvOneOf: [][]string{{"OPENAI_API_KEY", "ANTHROPIC_API_KEY"}}, + } + + diags := resolver.Resolve(reqs) + if len(diags) != 1 { + t.Fatalf("expected 1 diagnostic, got %d", len(diags)) + } + if diags[0].Level != "error" { + t.Errorf("diagnostic level = %q, want error", diags[0].Level) + } +} + +func TestResolve_MissingOptional_Warning(t *testing.T) { + resolver := NewEnvResolver(nil, nil, nil) + reqs := &AggregatedRequirements{ + EnvOptional: []string{"DEBUG"}, + } + + diags := resolver.Resolve(reqs) + if len(diags) != 1 { + t.Fatalf("expected 1 diagnostic, got %d", len(diags)) + } + if diags[0].Level != "warning" { + t.Errorf("diagnostic level = %q, want warning", diags[0].Level) + } +} + +func TestResolve_OneOfPartialSatisfied(t *testing.T) { + osEnv := map[string]string{ + "ANTHROPIC_API_KEY": "sk-ant-123", + } + resolver := NewEnvResolver(osEnv, nil, nil) + reqs := &AggregatedRequirements{ + EnvOneOf: [][]string{{"OPENAI_API_KEY", "ANTHROPIC_API_KEY"}}, + } + + diags := resolver.Resolve(reqs) + if len(diags) != 0 { + t.Errorf("expected 0 diagnostics, got %d: %+v", len(diags), diags) + } +} diff --git a/forge-core/skills/parser.go b/forge-core/skills/parser.go new file mode 100644 index 0000000..c451e98 --- /dev/null +++ b/forge-core/skills/parser.go @@ -0,0 +1,194 @@ +// Package skills provides a reusable parser for skills.md files. +package skills + +import ( + "bufio" + "bytes" + "io" + "strings" + + "gopkg.in/yaml.v3" +) + +// Parse reads skill entries from an io.Reader and extracts structured SkillEntry values. +// +// Supported formats: +// - "## Tool: " heading starts a new entry; paragraph lines become Description +// - "**Input:** " sets InputSpec on the current entry +// - "**Output:** " sets OutputSpec on the current entry +// - "- " (single-word/hyphenated list item) creates an entry with Name only (legacy) +func Parse(r io.Reader) ([]SkillEntry, error) { + var entries []SkillEntry + var current *SkillEntry + + finalize := func() { + if current != nil { + current.Description = strings.TrimSpace(current.Description) + entries = append(entries, *current) + current = nil + } + } + + scanner := bufio.NewScanner(r) + for scanner.Scan() { + line := scanner.Text() + trimmed := strings.TrimSpace(line) + + // "## Tool: " heading + if strings.HasPrefix(trimmed, "## Tool:") { + finalize() + name := strings.TrimSpace(strings.TrimPrefix(trimmed, "## Tool:")) + if name != "" { + current = &SkillEntry{Name: name} + } + continue + } + + // Another heading terminates current entry + if strings.HasPrefix(trimmed, "#") { + finalize() + continue + } + + // Inside a tool entry + if current != nil { + if strings.HasPrefix(trimmed, "**Input:**") { + current.InputSpec = strings.TrimSpace(strings.TrimPrefix(trimmed, "**Input:**")) + continue + } + if strings.HasPrefix(trimmed, "**Output:**") { + current.OutputSpec = strings.TrimSpace(strings.TrimPrefix(trimmed, "**Output:**")) + continue + } + // Paragraph text becomes description + if trimmed != "" { + if current.Description != "" { + current.Description += " " + } + current.Description += trimmed + } + continue + } + + // Legacy: "- " list items (single-word, no spaces, max 64 chars) + if strings.HasPrefix(trimmed, "- ") { + name := strings.TrimSpace(strings.TrimPrefix(trimmed, "- ")) + if name != "" && !strings.Contains(name, " ") && len(name) <= 64 { + entries = append(entries, SkillEntry{Name: name}) + } + } + } + + finalize() + return entries, scanner.Err() +} + +// ParseWithMetadata extracts optional YAML frontmatter (between --- delimiters) +// then passes the markdown body through existing Parse(). Returns entries with +// metadata attached, plus the top-level SkillMetadata. +func ParseWithMetadata(r io.Reader) ([]SkillEntry, *SkillMetadata, error) { + content, err := io.ReadAll(r) + if err != nil { + return nil, nil, err + } + + fm, body, hasFM := extractFrontmatter(content) + + var meta *SkillMetadata + if hasFM { + meta = &SkillMetadata{} + if err := yaml.Unmarshal(fm, meta); err != nil { + return nil, nil, err + } + } + + var forgeReqs *SkillRequirements + if meta != nil { + forgeReqs = extractForgeReqs(meta) + } + + entries, err := Parse(bytes.NewReader(body)) + if err != nil { + return nil, meta, err + } + + // Attach metadata to each entry + for i := range entries { + entries[i].Metadata = meta + entries[i].ForgeReqs = forgeReqs + } + + return entries, meta, nil +} + +// extractFrontmatter splits content at --- delimiters. +// Returns (frontmatter, body, hasFrontmatter). +func extractFrontmatter(content []byte) ([]byte, []byte, bool) { + trimmed := bytes.TrimLeft(content, " \t\r\n") + if !bytes.HasPrefix(trimmed, []byte("---")) { + return nil, content, false + } + + // Find the opening --- + start := bytes.Index(trimmed, []byte("---")) + afterOpen := start + 3 + + // Skip to the next line + nlIdx := bytes.IndexByte(trimmed[afterOpen:], '\n') + if nlIdx < 0 { + return nil, content, false + } + fmStart := afterOpen + nlIdx + 1 + + // Find closing --- + rest := trimmed[fmStart:] + closeIdx := -1 + scanner := bufio.NewScanner(bytes.NewReader(rest)) + pos := 0 + for scanner.Scan() { + line := scanner.Text() + if strings.TrimSpace(line) == "---" { + closeIdx = pos + break + } + pos += len(line) + 1 // +1 for \n + } + + if closeIdx < 0 { + return nil, content, false + } + + fm := rest[:closeIdx] + body := rest[closeIdx+3:] // skip past "---" + // Trim leading newline from body + if len(body) > 0 && body[0] == '\n' { + body = body[1:] + } + + return fm, body, true +} + +// extractForgeReqs extracts SkillRequirements from the generic metadata map +// by re-marshaling metadata["forge"] through yaml round-trip into ForgeSkillMeta. +func extractForgeReqs(meta *SkillMetadata) *SkillRequirements { + if meta == nil || meta.Metadata == nil { + return nil + } + forgeMap, ok := meta.Metadata["forge"] + if !ok || forgeMap == nil { + return nil + } + + // Re-marshal the forge map to YAML, then unmarshal into ForgeSkillMeta + data, err := yaml.Marshal(forgeMap) + if err != nil { + return nil + } + + var forgeMeta ForgeSkillMeta + if err := yaml.Unmarshal(data, &forgeMeta); err != nil { + return nil + } + + return forgeMeta.Requires +} diff --git a/forge-core/skills/parser_test.go b/forge-core/skills/parser_test.go new file mode 100644 index 0000000..b3506fc --- /dev/null +++ b/forge-core/skills/parser_test.go @@ -0,0 +1,331 @@ +package skills + +import ( + "reflect" + "strings" + "testing" +) + +func TestParse_HeadingFormat(t *testing.T) { + input := `# My Agent Skills + +## Tool: web_search + +A tool for searching the web. + +**Input:** query string +**Output:** list of results + +## Tool: sql_query + +Run SQL queries against the database. +` + entries, err := Parse(strings.NewReader(input)) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(entries)) + } + + if entries[0].Name != "web_search" { + t.Errorf("entry[0].Name = %q, want web_search", entries[0].Name) + } + if entries[0].Description != "A tool for searching the web." { + t.Errorf("entry[0].Description = %q", entries[0].Description) + } + if entries[0].InputSpec != "query string" { + t.Errorf("entry[0].InputSpec = %q, want 'query string'", entries[0].InputSpec) + } + if entries[0].OutputSpec != "list of results" { + t.Errorf("entry[0].OutputSpec = %q, want 'list of results'", entries[0].OutputSpec) + } + + if entries[1].Name != "sql_query" { + t.Errorf("entry[1].Name = %q, want sql_query", entries[1].Name) + } + if entries[1].Description != "Run SQL queries against the database." { + t.Errorf("entry[1].Description = %q", entries[1].Description) + } +} + +func TestParse_LegacyListItems(t *testing.T) { + input := `# Tools + +- calculator +- translator +- this is a sentence and should be ignored +` + entries, err := Parse(strings.NewReader(input)) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(entries)) + } + if entries[0].Name != "calculator" { + t.Errorf("entry[0].Name = %q, want calculator", entries[0].Name) + } + if entries[1].Name != "translator" { + t.Errorf("entry[1].Name = %q, want translator", entries[1].Name) + } +} + +func TestParse_Mixed(t *testing.T) { + input := `# Skills + +## Tool: api_client + +Calls external APIs. + +- helper_util +` + entries, err := Parse(strings.NewReader(input)) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + // api_client from heading, helper_util should NOT be captured because we're inside a tool entry + // Actually, "- helper_util" is inside current entry so it's treated as description text + // That's fine, the legacy list items only work outside of a tool entry + if len(entries) != 1 { + t.Fatalf("expected 1 entry, got %d: %+v", len(entries), entries) + } + if entries[0].Name != "api_client" { + t.Errorf("entry[0].Name = %q, want api_client", entries[0].Name) + } +} + +func TestParse_MixedOutsideEntry(t *testing.T) { + input := `# Skills + +## Tool: api_client + +Calls external APIs. + +# Other section + +- helper_util +` + entries, err := Parse(strings.NewReader(input)) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d: %+v", len(entries), entries) + } + if entries[0].Name != "api_client" { + t.Errorf("entry[0].Name = %q, want api_client", entries[0].Name) + } + if entries[1].Name != "helper_util" { + t.Errorf("entry[1].Name = %q, want helper_util", entries[1].Name) + } +} + +func TestParse_Empty(t *testing.T) { + entries, err := Parse(strings.NewReader(``)) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + if len(entries) != 0 { + t.Errorf("expected 0 entries, got %d", len(entries)) + } +} + +func TestParse_MultilineDescription(t *testing.T) { + input := `## Tool: complex_tool + +This tool does many things. +It has a long description +spanning multiple lines. + +**Input:** JSON payload +**Output:** processed result +` + entries, err := Parse(strings.NewReader(input)) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + if len(entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(entries)) + } + want := "This tool does many things. It has a long description spanning multiple lines." + if entries[0].Description != want { + t.Errorf("Description = %q, want %q", entries[0].Description, want) + } +} + +func TestParseWithMetadata_NoFrontmatter(t *testing.T) { + input := `## Tool: web_search +A tool for searching the web. + +**Input:** query string +**Output:** list of results +` + entries, meta, err := ParseWithMetadata(strings.NewReader(input)) + if err != nil { + t.Fatalf("ParseWithMetadata error: %v", err) + } + if meta != nil { + t.Error("expected nil metadata for no frontmatter") + } + if len(entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(entries)) + } + if entries[0].Name != "web_search" { + t.Errorf("entry[0].Name = %q, want web_search", entries[0].Name) + } + if entries[0].Metadata != nil { + t.Error("expected nil Metadata on entry") + } + if entries[0].ForgeReqs != nil { + t.Error("expected nil ForgeReqs on entry") + } +} + +func TestParseWithMetadata_WithForgeRequires(t *testing.T) { + input := `--- +name: summarize +description: Summarize URLs or files +metadata: + forge: + requires: + bins: + - curl + - jq + env: + required: + - API_KEY + one_of: + - OPENAI_API_KEY + - ANTHROPIC_API_KEY + optional: + - FIRECRAWL_API_KEY +--- +## Tool: summarize +Summarize URLs or files into concise text. + +**Input:** url: string +**Output:** summary: string +` + entries, meta, err := ParseWithMetadata(strings.NewReader(input)) + if err != nil { + t.Fatalf("ParseWithMetadata error: %v", err) + } + if meta == nil { + t.Fatal("expected non-nil metadata") + } + if meta.Name != "summarize" { + t.Errorf("meta.Name = %q, want summarize", meta.Name) + } + if meta.Description != "Summarize URLs or files" { + t.Errorf("meta.Description = %q", meta.Description) + } + if len(entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(entries)) + } + if entries[0].ForgeReqs == nil { + t.Fatal("expected non-nil ForgeReqs") + } + if !reflect.DeepEqual(entries[0].ForgeReqs.Bins, []string{"curl", "jq"}) { + t.Errorf("Bins = %v, want [curl jq]", entries[0].ForgeReqs.Bins) + } + if entries[0].ForgeReqs.Env == nil { + t.Fatal("expected non-nil Env") + } + if !reflect.DeepEqual(entries[0].ForgeReqs.Env.Required, []string{"API_KEY"}) { + t.Errorf("Env.Required = %v, want [API_KEY]", entries[0].ForgeReqs.Env.Required) + } + if !reflect.DeepEqual(entries[0].ForgeReqs.Env.OneOf, []string{"OPENAI_API_KEY", "ANTHROPIC_API_KEY"}) { + t.Errorf("Env.OneOf = %v", entries[0].ForgeReqs.Env.OneOf) + } + if !reflect.DeepEqual(entries[0].ForgeReqs.Env.Optional, []string{"FIRECRAWL_API_KEY"}) { + t.Errorf("Env.Optional = %v", entries[0].ForgeReqs.Env.Optional) + } +} + +func TestParseWithMetadata_UnknownNamespaces(t *testing.T) { + input := `--- +name: myskill +metadata: + forge: + requires: + bins: + - python + clawdbot: + priority: high +--- +## Tool: myskill +Does things. +` + entries, meta, err := ParseWithMetadata(strings.NewReader(input)) + if err != nil { + t.Fatalf("ParseWithMetadata error: %v", err) + } + if meta == nil { + t.Fatal("expected non-nil metadata") + } + // clawdbot namespace should be tolerated + if _, ok := meta.Metadata["clawdbot"]; !ok { + t.Error("expected clawdbot namespace in metadata") + } + if len(entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(entries)) + } + if entries[0].ForgeReqs == nil { + t.Fatal("expected non-nil ForgeReqs") + } + if !reflect.DeepEqual(entries[0].ForgeReqs.Bins, []string{"python"}) { + t.Errorf("Bins = %v, want [python]", entries[0].ForgeReqs.Bins) + } +} + +func TestParseWithMetadata_EmptyFrontmatter(t *testing.T) { + input := `--- +--- +## Tool: simple +A simple tool. +` + entries, meta, err := ParseWithMetadata(strings.NewReader(input)) + if err != nil { + t.Fatalf("ParseWithMetadata error: %v", err) + } + if meta == nil { + t.Fatal("expected non-nil metadata (even if empty)") + } + if len(entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(entries)) + } + if entries[0].ForgeReqs != nil { + t.Error("expected nil ForgeReqs for empty frontmatter") + } +} + +func TestParseWithMetadata_FrontmatterOverridesName(t *testing.T) { + input := `--- +name: frontmatter-name +description: From frontmatter +--- +## Tool: tool-name +Tool description. +` + entries, meta, err := ParseWithMetadata(strings.NewReader(input)) + if err != nil { + t.Fatalf("ParseWithMetadata error: %v", err) + } + if meta.Name != "frontmatter-name" { + t.Errorf("meta.Name = %q, want frontmatter-name", meta.Name) + } + if meta.Description != "From frontmatter" { + t.Errorf("meta.Description = %q, want 'From frontmatter'", meta.Description) + } + // Entry name comes from ## Tool: heading, metadata is attached + if len(entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(entries)) + } + if entries[0].Name != "tool-name" { + t.Errorf("entry name = %q, want tool-name", entries[0].Name) + } + if entries[0].Metadata != meta { + t.Error("expected entry metadata to point to same SkillMetadata") + } +} diff --git a/forge-core/skills/requirements.go b/forge-core/skills/requirements.go new file mode 100644 index 0000000..abd6f46 --- /dev/null +++ b/forge-core/skills/requirements.go @@ -0,0 +1,71 @@ +package skills + +import "sort" + +// AggregatedRequirements is the union of all skill requirements. +type AggregatedRequirements struct { + Bins []string // union of all bins, deduplicated, sorted + EnvRequired []string // union of required vars (promoted from optional if needed) + EnvOneOf [][]string // separate groups per skill (not merged across skills) + EnvOptional []string // union of optional vars minus those promoted to required +} + +// AggregateRequirements merges requirements from all entries that have ForgeReqs set. +// +// Promotion rules: +// - var in both required (skill A) and optional (skill B) → required +// - var in one_of (skill A) and required (skill B) → stays in required (group still exists) +// - one_of groups kept separate per skill +func AggregateRequirements(entries []SkillEntry) *AggregatedRequirements { + binSet := make(map[string]bool) + reqSet := make(map[string]bool) + optSet := make(map[string]bool) + var oneOfGroups [][]string + + for _, e := range entries { + if e.ForgeReqs == nil { + continue + } + for _, b := range e.ForgeReqs.Bins { + binSet[b] = true + } + if e.ForgeReqs.Env != nil { + for _, v := range e.ForgeReqs.Env.Required { + reqSet[v] = true + } + if len(e.ForgeReqs.Env.OneOf) > 0 { + oneOfGroups = append(oneOfGroups, e.ForgeReqs.Env.OneOf) + } + for _, v := range e.ForgeReqs.Env.Optional { + optSet[v] = true + } + } + } + + // Promotion: optional vars that appear in required get promoted + for v := range optSet { + if reqSet[v] { + delete(optSet, v) + } + } + + agg := &AggregatedRequirements{ + Bins: sortedKeys(binSet), + EnvOneOf: oneOfGroups, + } + agg.EnvRequired = sortedKeys(reqSet) + agg.EnvOptional = sortedKeys(optSet) + return agg +} + +func sortedKeys(m map[string]bool) []string { + if len(m) == 0 { + return nil + } + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/forge-core/skills/requirements_test.go b/forge-core/skills/requirements_test.go new file mode 100644 index 0000000..6d0d67e --- /dev/null +++ b/forge-core/skills/requirements_test.go @@ -0,0 +1,143 @@ +package skills + +import ( + "testing" +) + +func TestAggregate_SingleSkill(t *testing.T) { + entries := []SkillEntry{ + { + Name: "summarize", + ForgeReqs: &SkillRequirements{ + Bins: []string{"curl", "jq"}, + Env: &EnvRequirements{ + Required: []string{"API_KEY"}, + Optional: []string{"TIMEOUT"}, + }, + }, + }, + } + + reqs := AggregateRequirements(entries) + if len(reqs.Bins) != 2 { + t.Errorf("expected 2 bins, got %d", len(reqs.Bins)) + } + if reqs.Bins[0] != "curl" || reqs.Bins[1] != "jq" { + t.Errorf("bins = %v, want [curl jq]", reqs.Bins) + } + if len(reqs.EnvRequired) != 1 || reqs.EnvRequired[0] != "API_KEY" { + t.Errorf("EnvRequired = %v, want [API_KEY]", reqs.EnvRequired) + } + if len(reqs.EnvOptional) != 1 || reqs.EnvOptional[0] != "TIMEOUT" { + t.Errorf("EnvOptional = %v, want [TIMEOUT]", reqs.EnvOptional) + } +} + +func TestAggregate_MultiSkill_BinsUnion(t *testing.T) { + entries := []SkillEntry{ + { + Name: "a", + ForgeReqs: &SkillRequirements{Bins: []string{"curl", "jq"}}, + }, + { + Name: "b", + ForgeReqs: &SkillRequirements{Bins: []string{"jq", "python"}}, + }, + } + + reqs := AggregateRequirements(entries) + if len(reqs.Bins) != 3 { + t.Errorf("expected 3 bins, got %d: %v", len(reqs.Bins), reqs.Bins) + } + // Should be sorted and deduplicated + expected := []string{"curl", "jq", "python"} + for i, b := range expected { + if reqs.Bins[i] != b { + t.Errorf("bins[%d] = %q, want %q", i, reqs.Bins[i], b) + } + } +} + +func TestAggregate_PromotionOptionalToRequired(t *testing.T) { + entries := []SkillEntry{ + { + Name: "a", + ForgeReqs: &SkillRequirements{ + Env: &EnvRequirements{ + Required: []string{"API_KEY"}, + }, + }, + }, + { + Name: "b", + ForgeReqs: &SkillRequirements{ + Env: &EnvRequirements{ + Optional: []string{"API_KEY", "DEBUG"}, + }, + }, + }, + } + + reqs := AggregateRequirements(entries) + // API_KEY should be promoted to required (from optional in skill B) + if len(reqs.EnvRequired) != 1 || reqs.EnvRequired[0] != "API_KEY" { + t.Errorf("EnvRequired = %v, want [API_KEY]", reqs.EnvRequired) + } + // DEBUG should remain optional + if len(reqs.EnvOptional) != 1 || reqs.EnvOptional[0] != "DEBUG" { + t.Errorf("EnvOptional = %v, want [DEBUG]", reqs.EnvOptional) + } +} + +func TestAggregate_OneOfKeptSeparate(t *testing.T) { + entries := []SkillEntry{ + { + Name: "a", + ForgeReqs: &SkillRequirements{ + Env: &EnvRequirements{ + OneOf: []string{"OPENAI_API_KEY", "ANTHROPIC_API_KEY"}, + }, + }, + }, + { + Name: "b", + ForgeReqs: &SkillRequirements{ + Env: &EnvRequirements{ + OneOf: []string{"GCP_KEY", "AWS_KEY"}, + }, + }, + }, + } + + reqs := AggregateRequirements(entries) + if len(reqs.EnvOneOf) != 2 { + t.Fatalf("expected 2 oneOf groups, got %d", len(reqs.EnvOneOf)) + } + if len(reqs.EnvOneOf[0]) != 2 { + t.Errorf("group 0 = %v, want 2 items", reqs.EnvOneOf[0]) + } + if len(reqs.EnvOneOf[1]) != 2 { + t.Errorf("group 1 = %v, want 2 items", reqs.EnvOneOf[1]) + } +} + +func TestAggregate_NoRequirements(t *testing.T) { + entries := []SkillEntry{ + {Name: "a"}, + {Name: "b"}, + } + + reqs := AggregateRequirements(entries) + if len(reqs.Bins) != 0 { + t.Errorf("expected 0 bins, got %d", len(reqs.Bins)) + } + if len(reqs.EnvRequired) != 0 { + t.Errorf("expected 0 required, got %d", len(reqs.EnvRequired)) + } + if len(reqs.EnvOptional) != 0 { + t.Errorf("expected 0 optional, got %d", len(reqs.EnvOptional)) + } + if len(reqs.EnvOneOf) != 0 { + t.Errorf("expected 0 oneOf, got %d", len(reqs.EnvOneOf)) + } +} diff --git a/forge-core/skills/types.go b/forge-core/skills/types.go new file mode 100644 index 0000000..2bdf90e --- /dev/null +++ b/forge-core/skills/types.go @@ -0,0 +1,37 @@ +package skills + +// SkillEntry represents a single tool/skill parsed from a skills.md file. +type SkillEntry struct { + Name string + Description string + InputSpec string + OutputSpec string + Metadata *SkillMetadata // nil if no frontmatter + ForgeReqs *SkillRequirements // convenience: extracted from metadata.forge.requires +} + +// SkillMetadata holds the full frontmatter parsed from YAML between --- delimiters. +// Uses map to tolerate unknown namespaces (e.g. clawdbot:). +type SkillMetadata struct { + Name string `yaml:"name,omitempty"` + Description string `yaml:"description,omitempty"` + Metadata map[string]map[string]any `yaml:"metadata,omitempty"` +} + +// ForgeSkillMeta holds Forge-specific metadata from the "forge" namespace. +type ForgeSkillMeta struct { + Requires *SkillRequirements `yaml:"requires,omitempty" json:"requires,omitempty"` +} + +// SkillRequirements declares CLI binaries and environment variables a skill needs. +type SkillRequirements struct { + Bins []string `yaml:"bins,omitempty" json:"bins,omitempty"` + Env *EnvRequirements `yaml:"env,omitempty" json:"env,omitempty"` +} + +// EnvRequirements declares environment variable requirements at different levels. +type EnvRequirements struct { + Required []string `yaml:"required,omitempty" json:"required,omitempty"` + OneOf []string `yaml:"one_of,omitempty" json:"one_of,omitempty"` + Optional []string `yaml:"optional,omitempty" json:"optional,omitempty"` +} diff --git a/forge-core/tools/adapters/adapters_test.go b/forge-core/tools/adapters/adapters_test.go new file mode 100644 index 0000000..c0c4fc4 --- /dev/null +++ b/forge-core/tools/adapters/adapters_test.go @@ -0,0 +1,94 @@ +package adapters + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/initializ/forge/forge-core/tools" +) + +func TestWebhookCallTool(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("method: got %q", r.Method) + } + if r.Header.Get("Content-Type") != "application/json" { + t.Errorf("content-type: got %q", r.Header.Get("Content-Type")) + } + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"received":true}`)) //nolint:errcheck + })) + defer ts.Close() + + tool := NewWebhookCallTool() + if tool.Name() != "webhook_call" { + t.Errorf("name: got %q", tool.Name()) + } + if tool.Category() != tools.CategoryAdapter { + t.Errorf("category: got %q", tool.Category()) + } + + args, _ := json.Marshal(map[string]any{ + "url": ts.URL, + "payload": map[string]string{"msg": "hello"}, + }) + + result, err := tool.Execute(context.Background(), args) + if err != nil { + t.Fatalf("Execute error: %v", err) + } + if !strings.Contains(result, "received") { + t.Errorf("result: %q", result) + } +} + +func TestMCPCallTool(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"content":"tool result"}}`)) //nolint:errcheck + })) + defer ts.Close() + + tool := NewMCPCallTool() + if tool.Name() != "mcp_call" { + t.Errorf("name: got %q", tool.Name()) + } + + args, _ := json.Marshal(map[string]any{ + "server_url": ts.URL, + "tool_name": "test_tool", + "arguments": map[string]string{"key": "val"}, + }) + + result, err := tool.Execute(context.Background(), args) + if err != nil { + t.Fatalf("Execute error: %v", err) + } + if !strings.Contains(result, "tool result") { + t.Errorf("result: %q", result) + } +} + +func TestOpenAPICallTool(t *testing.T) { + tool := NewOpenAPICallTool() + if tool.Name() != "openapi_call" { + t.Errorf("name: got %q", tool.Name()) + } + + args, _ := json.Marshal(map[string]any{ + "spec_url": "https://example.com/api.json", + "operation_id": "getUser", + }) + + result, err := tool.Execute(context.Background(), args) + if err != nil { + t.Fatalf("Execute error: %v", err) + } + if !strings.Contains(result, "not yet implemented") { + t.Errorf("expected stub message: %q", result) + } +} diff --git a/forge-core/tools/adapters/mcp_call.go b/forge-core/tools/adapters/mcp_call.go new file mode 100644 index 0000000..9cd6d6e --- /dev/null +++ b/forge-core/tools/adapters/mcp_call.go @@ -0,0 +1,75 @@ +package adapters + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/initializ/forge/forge-core/tools" +) + +type mcpCallTool struct{} + +type mcpCallInput struct { + ServerURL string `json:"server_url"` + ToolName string `json:"tool_name"` + Arguments json.RawMessage `json:"arguments,omitempty"` +} + +func (t *mcpCallTool) Name() string { return "mcp_call" } +func (t *mcpCallTool) Description() string { return "Call a tool on an MCP server via JSON-RPC" } +func (t *mcpCallTool) Category() tools.Category { return tools.CategoryAdapter } + +func (t *mcpCallTool) InputSchema() json.RawMessage { + return json.RawMessage(`{ + "type": "object", + "properties": { + "server_url": {"type": "string", "description": "MCP server URL"}, + "tool_name": {"type": "string", "description": "Tool name to invoke"}, + "arguments": {"type": "object", "description": "Tool arguments"} + }, + "required": ["server_url", "tool_name"] + }`) +} + +func (t *mcpCallTool) Execute(ctx context.Context, args json.RawMessage) (string, error) { + var input mcpCallInput + if err := json.Unmarshal(args, &input); err != nil { + return "", fmt.Errorf("parsing input: %w", err) + } + + // Build JSON-RPC request + rpcReq := map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": map[string]any{ + "name": input.ToolName, + "arguments": input.Arguments, + }, + } + + data, _ := json.Marshal(rpcReq) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, input.ServerURL, bytes.NewReader(data)) + if err != nil { + return "", fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("mcp call: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + return string(body), nil +} + +// NewMCPCallTool creates an MCP call tool. +func NewMCPCallTool() tools.Tool { return &mcpCallTool{} } diff --git a/forge-core/tools/adapters/openapi_call.go b/forge-core/tools/adapters/openapi_call.go new file mode 100644 index 0000000..0263c79 --- /dev/null +++ b/forge-core/tools/adapters/openapi_call.go @@ -0,0 +1,47 @@ +package adapters + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/initializ/forge/forge-core/tools" +) + +type openapiCallTool struct{} + +type openapiCallInput struct { + SpecURL string `json:"spec_url"` + OperationID string `json:"operation_id"` + Params json.RawMessage `json:"params,omitempty"` +} + +func (t *openapiCallTool) Name() string { return "openapi_call" } +func (t *openapiCallTool) Description() string { + return "Call an OpenAPI endpoint by operation ID (stub)" +} +func (t *openapiCallTool) Category() tools.Category { return tools.CategoryAdapter } + +func (t *openapiCallTool) InputSchema() json.RawMessage { + return json.RawMessage(`{ + "type": "object", + "properties": { + "spec_url": {"type": "string", "description": "URL to OpenAPI spec"}, + "operation_id": {"type": "string", "description": "Operation ID to invoke"}, + "params": {"type": "object", "description": "Parameters for the operation"} + }, + "required": ["spec_url", "operation_id"] + }`) +} + +func (t *openapiCallTool) Execute(ctx context.Context, args json.RawMessage) (string, error) { + var input openapiCallInput + if err := json.Unmarshal(args, &input); err != nil { + return "", fmt.Errorf("parsing input: %w", err) + } + + return fmt.Sprintf(`{"error": "OpenAPI call not yet implemented. Spec: %s, Operation: %s"}`, input.SpecURL, input.OperationID), nil +} + +// NewOpenAPICallTool creates an OpenAPI call tool. +func NewOpenAPICallTool() tools.Tool { return &openapiCallTool{} } diff --git a/forge-core/tools/adapters/webhook_call.go b/forge-core/tools/adapters/webhook_call.go new file mode 100644 index 0000000..ad3fcf2 --- /dev/null +++ b/forge-core/tools/adapters/webhook_call.go @@ -0,0 +1,73 @@ +// Package adapters provides tools that integrate with external services. +package adapters + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/initializ/forge/forge-core/tools" +) + +type webhookCallTool struct{} + +type webhookCallInput struct { + URL string `json:"url"` + Payload json.RawMessage `json:"payload"` + Headers map[string]string `json:"headers,omitempty"` +} + +func (t *webhookCallTool) Name() string { return "webhook_call" } +func (t *webhookCallTool) Description() string { return "POST JSON payload to a webhook URL" } +func (t *webhookCallTool) Category() tools.Category { return tools.CategoryAdapter } + +func (t *webhookCallTool) InputSchema() json.RawMessage { + return json.RawMessage(`{ + "type": "object", + "properties": { + "url": {"type": "string", "description": "Webhook URL to POST to"}, + "payload": {"type": "object", "description": "JSON payload to send"}, + "headers": {"type": "object", "additionalProperties": {"type": "string"}, "description": "Additional HTTP headers"} + }, + "required": ["url", "payload"] + }`) +} + +func (t *webhookCallTool) Execute(ctx context.Context, args json.RawMessage) (string, error) { + var input webhookCallInput + if err := json.Unmarshal(args, &input); err != nil { + return "", fmt.Errorf("parsing input: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, input.URL, bytes.NewReader(input.Payload)) + if err != nil { + return "", fmt.Errorf("creating request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + for k, v := range input.Headers { + req.Header.Set(k, v) + } + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("webhook call: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + result := map[string]any{ + "status": resp.StatusCode, + "body": string(body), + } + data, _ := json.Marshal(result) + return string(data), nil +} + +// NewWebhookCallTool creates a webhook call tool. +func NewWebhookCallTool() tools.Tool { return &webhookCallTool{} } diff --git a/forge-core/tools/builtins/builtins_test.go b/forge-core/tools/builtins/builtins_test.go new file mode 100644 index 0000000..ea34a27 --- /dev/null +++ b/forge-core/tools/builtins/builtins_test.go @@ -0,0 +1,209 @@ +package builtins + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/initializ/forge/forge-core/tools" +) + +func TestRegisterAll(t *testing.T) { + reg := tools.NewRegistry() + if err := RegisterAll(reg); err != nil { + t.Fatalf("RegisterAll error: %v", err) + } + + expected := []string{ + "http_request", "json_parse", "csv_parse", + "datetime_now", "uuid_generate", "math_calculate", "web_search", + } + for _, name := range expected { + if reg.Get(name) == nil { + t.Errorf("expected tool %q to be registered", name) + } + } +} + +func TestGetByName(t *testing.T) { + tool := GetByName("json_parse") + if tool == nil { + t.Fatal("expected non-nil tool") + } + if tool.Name() != "json_parse" { + t.Errorf("name: got %q", tool.Name()) + } + + if GetByName("nonexistent") != nil { + t.Error("expected nil for nonexistent tool") + } +} + +func TestHTTPRequestTool(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"message":"hello"}`)) //nolint:errcheck + })) + defer ts.Close() + + tool := GetByName("http_request") + args, _ := json.Marshal(map[string]any{ + "method": "GET", + "url": ts.URL, + }) + + result, err := tool.Execute(context.Background(), args) + if err != nil { + t.Fatalf("Execute error: %v", err) + } + if !strings.Contains(result, "hello") { + t.Errorf("result should contain response: %q", result) + } +} + +func TestJSONParseTool(t *testing.T) { + tool := GetByName("json_parse") + + t.Run("parse", func(t *testing.T) { + args, _ := json.Marshal(map[string]string{ + "data": `{"name":"John","age":"30"}`, + }) + result, err := tool.Execute(context.Background(), args) + if err != nil { + t.Fatalf("Execute error: %v", err) + } + if !strings.Contains(result, "John") { + t.Errorf("result should contain name: %q", result) + } + }) + + t.Run("query", func(t *testing.T) { + args, _ := json.Marshal(map[string]string{ + "data": `{"user":{"name":"Jane"}}`, + "query": "user.name", + }) + result, err := tool.Execute(context.Background(), args) + if err != nil { + t.Fatalf("Execute error: %v", err) + } + if !strings.Contains(result, "Jane") { + t.Errorf("result should contain queried value: %q", result) + } + }) +} + +func TestCSVParseTool(t *testing.T) { + tool := GetByName("csv_parse") + args, _ := json.Marshal(map[string]any{ + "data": "name,age\nAlice,30\nBob,25", + }) + + result, err := tool.Execute(context.Background(), args) + if err != nil { + t.Fatalf("Execute error: %v", err) + } + if !strings.Contains(result, "Alice") { + t.Errorf("result should contain Alice: %q", result) + } +} + +func TestDatetimeNowTool(t *testing.T) { + tool := GetByName("datetime_now") + args, _ := json.Marshal(map[string]string{ + "format": "date", + "timezone": "UTC", + }) + + result, err := tool.Execute(context.Background(), args) + if err != nil { + t.Fatalf("Execute error: %v", err) + } + // Should be in YYYY-MM-DD format + if len(result) != 10 || result[4] != '-' { + t.Errorf("unexpected date format: %q", result) + } +} + +func TestUUIDGenerateTool(t *testing.T) { + tool := GetByName("uuid_generate") + result, err := tool.Execute(context.Background(), json.RawMessage("{}")) + if err != nil { + t.Fatalf("Execute error: %v", err) + } + // UUID v4 format: 8-4-4-4-12 + if len(result) != 36 || result[8] != '-' || result[13] != '-' || result[18] != '-' || result[23] != '-' { + t.Errorf("invalid UUID format: %q", result) + } +} + +func TestMathCalculateTool(t *testing.T) { + tool := GetByName("math_calculate") + + tests := []struct { + expr string + want string + }{ + {"2 + 3", "5"}, + {"10 - 4", "6"}, + {"3 * 4", "12"}, + {"15 / 3", "5"}, + {"(2 + 3) * 4", "20"}, + {"sqrt(16)", "4"}, + {"pow(2, 10)", "1024"}, + {"abs(-5)", "5"}, + } + + for _, tt := range tests { + t.Run(tt.expr, func(t *testing.T) { + args, _ := json.Marshal(map[string]string{"expression": tt.expr}) + result, err := tool.Execute(context.Background(), args) + if err != nil { + t.Fatalf("Execute error for %q: %v", tt.expr, err) + } + if result != tt.want { + t.Errorf("got %q, want %q", result, tt.want) + } + }) + } +} + +func TestMathCalculateTool_DivisionByZero(t *testing.T) { + tool := GetByName("math_calculate") + args, _ := json.Marshal(map[string]string{"expression": "1 / 0"}) + _, err := tool.Execute(context.Background(), args) + if err == nil { + t.Error("expected error for division by zero") + } +} + +func TestWebSearchTool_NoKey(t *testing.T) { + orig := os.Getenv("PERPLEXITY_API_KEY") + _ = os.Unsetenv("PERPLEXITY_API_KEY") + defer func() { + if orig != "" { + _ = os.Setenv("PERPLEXITY_API_KEY", orig) + } + }() + + tool := GetByName("web_search") + args, _ := json.Marshal(map[string]string{"query": "test"}) + result, err := tool.Execute(context.Background(), args) + if err != nil { + t.Fatalf("Execute error: %v", err) + } + if !strings.Contains(result, "PERPLEXITY_API_KEY") { + t.Errorf("expected missing key message, got: %q", result) + } +} + +func TestAllToolsHaveCategory(t *testing.T) { + for _, tool := range All() { + if tool.Category() != tools.CategoryBuiltin { + t.Errorf("tool %q category: got %q, want %q", tool.Name(), tool.Category(), tools.CategoryBuiltin) + } + } +} diff --git a/forge-core/tools/builtins/csv_parse.go b/forge-core/tools/builtins/csv_parse.go new file mode 100644 index 0000000..df0df52 --- /dev/null +++ b/forge-core/tools/builtins/csv_parse.go @@ -0,0 +1,88 @@ +package builtins + +import ( + "context" + "encoding/csv" + "encoding/json" + "fmt" + "strings" + + "github.com/initializ/forge/forge-core/tools" +) + +type csvParseTool struct{} + +type csvParseInput struct { + Data string `json:"data"` + Delimiter string `json:"delimiter,omitempty"` + Headers bool `json:"headers,omitempty"` +} + +func (t *csvParseTool) Name() string { return "csv_parse" } +func (t *csvParseTool) Description() string { return "Parse CSV data into JSON array" } +func (t *csvParseTool) Category() tools.Category { return tools.CategoryBuiltin } + +func (t *csvParseTool) InputSchema() json.RawMessage { + return json.RawMessage(`{ + "type": "object", + "properties": { + "data": {"type": "string", "description": "CSV data to parse"}, + "delimiter": {"type": "string", "description": "Field delimiter (default comma)"}, + "headers": {"type": "boolean", "description": "First row contains headers (default true)"} + }, + "required": ["data"] + }`) +} + +func (t *csvParseTool) Execute(ctx context.Context, args json.RawMessage) (string, error) { + var input csvParseInput + if err := json.Unmarshal(args, &input); err != nil { + return "", fmt.Errorf("parsing input: %w", err) + } + + reader := csv.NewReader(strings.NewReader(input.Data)) + if input.Delimiter != "" { + runes := []rune(input.Delimiter) + if len(runes) > 0 { + reader.Comma = runes[0] + } + } + + records, err := reader.ReadAll() + if err != nil { + return "", fmt.Errorf("parsing CSV: %w", err) + } + + if len(records) == 0 { + return "[]", nil + } + + // Default: headers = true (unless explicitly set to false via JSON) + useHeaders := true + // Check if headers was explicitly set in the JSON + var raw map[string]json.RawMessage + if json.Unmarshal(args, &raw) == nil { + if _, ok := raw["headers"]; ok { + useHeaders = input.Headers + } + } + + if useHeaders && len(records) > 1 { + headers := records[0] + var result []map[string]string + for _, row := range records[1:] { + obj := make(map[string]string) + for i, val := range row { + if i < len(headers) { + obj[headers[i]] = val + } + } + result = append(result, obj) + } + data, _ := json.MarshalIndent(result, "", " ") + return string(data), nil + } + + data, _ := json.MarshalIndent(records, "", " ") + return string(data), nil +} diff --git a/forge-core/tools/builtins/datetime_now.go b/forge-core/tools/builtins/datetime_now.go new file mode 100644 index 0000000..ec2855d --- /dev/null +++ b/forge-core/tools/builtins/datetime_now.go @@ -0,0 +1,64 @@ +package builtins + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/initializ/forge/forge-core/tools" +) + +type datetimeNowTool struct{} + +type datetimeNowInput struct { + Format string `json:"format,omitempty"` + Timezone string `json:"timezone,omitempty"` +} + +func (t *datetimeNowTool) Name() string { return "datetime_now" } +func (t *datetimeNowTool) Description() string { + return "Get current date and time in specified format and timezone" +} +func (t *datetimeNowTool) Category() tools.Category { return tools.CategoryBuiltin } + +func (t *datetimeNowTool) InputSchema() json.RawMessage { + return json.RawMessage(`{ + "type": "object", + "properties": { + "format": {"type": "string", "description": "Time format (rfc3339, unix, date, time, datetime). Default: rfc3339"}, + "timezone": {"type": "string", "description": "Timezone name (e.g. America/New_York, UTC). Default: UTC"} + } + }`) +} + +func (t *datetimeNowTool) Execute(ctx context.Context, args json.RawMessage) (string, error) { + var input datetimeNowInput + if err := json.Unmarshal(args, &input); err != nil { + return "", fmt.Errorf("parsing input: %w", err) + } + + loc := time.UTC + if input.Timezone != "" { + var err error + loc, err = time.LoadLocation(input.Timezone) + if err != nil { + return "", fmt.Errorf("invalid timezone %q: %w", input.Timezone, err) + } + } + + now := time.Now().In(loc) + + switch input.Format { + case "unix": + return fmt.Sprintf("%d", now.Unix()), nil + case "date": + return now.Format("2006-01-02"), nil + case "time": + return now.Format("15:04:05"), nil + case "datetime": + return now.Format("2006-01-02 15:04:05"), nil + default: // "rfc3339" or empty + return now.Format(time.RFC3339), nil + } +} diff --git a/forge-core/tools/builtins/http_request.go b/forge-core/tools/builtins/http_request.go new file mode 100644 index 0000000..a095239 --- /dev/null +++ b/forge-core/tools/builtins/http_request.go @@ -0,0 +1,88 @@ +// Package builtins provides built-in tools available to all agents. +package builtins + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/initializ/forge/forge-core/tools" +) + +type httpRequestTool struct{} + +type httpRequestInput struct { + Method string `json:"method"` + URL string `json:"url"` + Headers map[string]string `json:"headers,omitempty"` + Body string `json:"body,omitempty"` + Timeout int `json:"timeout,omitempty"` +} + +func (t *httpRequestTool) Name() string { return "http_request" } +func (t *httpRequestTool) Description() string { return "Make HTTP requests (GET, POST, PUT, DELETE)" } +func (t *httpRequestTool) Category() tools.Category { return tools.CategoryBuiltin } + +func (t *httpRequestTool) InputSchema() json.RawMessage { + return json.RawMessage(`{ + "type": "object", + "properties": { + "method": {"type": "string", "enum": ["GET", "POST", "PUT", "DELETE"], "description": "HTTP method"}, + "url": {"type": "string", "description": "URL to send the request to"}, + "headers": {"type": "object", "additionalProperties": {"type": "string"}, "description": "Request headers"}, + "body": {"type": "string", "description": "Request body"}, + "timeout": {"type": "integer", "description": "Timeout in seconds (default 30)"} + }, + "required": ["method", "url"] + }`) +} + +func (t *httpRequestTool) Execute(ctx context.Context, args json.RawMessage) (string, error) { + var input httpRequestInput + if err := json.Unmarshal(args, &input); err != nil { + return "", fmt.Errorf("parsing input: %w", err) + } + + timeout := time.Duration(input.Timeout) * time.Second + if timeout == 0 { + timeout = 30 * time.Second + } + + var bodyReader io.Reader + if input.Body != "" { + bodyReader = strings.NewReader(input.Body) + } + + req, err := http.NewRequestWithContext(ctx, input.Method, input.URL, bodyReader) + if err != nil { + return "", fmt.Errorf("creating request: %w", err) + } + + for k, v := range input.Headers { + req.Header.Set(k, v) + } + + client := &http.Client{Timeout: timeout} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("executing request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1MB limit + if err != nil { + return "", fmt.Errorf("reading response: %w", err) + } + + result := map[string]any{ + "status": resp.StatusCode, + "status_text": resp.Status, + "body": string(body), + } + data, _ := json.Marshal(result) + return string(data), nil +} diff --git a/forge-core/tools/builtins/json_parse.go b/forge-core/tools/builtins/json_parse.go new file mode 100644 index 0000000..0efd471 --- /dev/null +++ b/forge-core/tools/builtins/json_parse.go @@ -0,0 +1,70 @@ +package builtins + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/initializ/forge/forge-core/tools" +) + +type jsonParseTool struct{} + +type jsonParseInput struct { + Data string `json:"data"` + Query string `json:"query,omitempty"` +} + +func (t *jsonParseTool) Name() string { return "json_parse" } +func (t *jsonParseTool) Description() string { + return "Parse JSON data and optionally query with dot notation" +} +func (t *jsonParseTool) Category() tools.Category { return tools.CategoryBuiltin } + +func (t *jsonParseTool) InputSchema() json.RawMessage { + return json.RawMessage(`{ + "type": "object", + "properties": { + "data": {"type": "string", "description": "JSON string to parse"}, + "query": {"type": "string", "description": "Dot-notation path to query (e.g. 'user.name')"} + }, + "required": ["data"] + }`) +} + +func (t *jsonParseTool) Execute(ctx context.Context, args json.RawMessage) (string, error) { + var input jsonParseInput + if err := json.Unmarshal(args, &input); err != nil { + return "", fmt.Errorf("parsing input: %w", err) + } + + var parsed any + if err := json.Unmarshal([]byte(input.Data), &parsed); err != nil { + return "", fmt.Errorf("invalid JSON: %w", err) + } + + if input.Query == "" { + data, _ := json.MarshalIndent(parsed, "", " ") + return string(data), nil + } + + // Dot-notation query + result := queryDotNotation(parsed, input.Query) + data, _ := json.MarshalIndent(result, "", " ") + return string(data), nil +} + +func queryDotNotation(data any, path string) any { + parts := strings.Split(path, ".") + current := data + for _, part := range parts { + switch v := current.(type) { + case map[string]any: + current = v[part] + default: + return nil + } + } + return current +} diff --git a/forge-core/tools/builtins/math_calculate.go b/forge-core/tools/builtins/math_calculate.go new file mode 100644 index 0000000..ff3195f --- /dev/null +++ b/forge-core/tools/builtins/math_calculate.go @@ -0,0 +1,274 @@ +package builtins + +import ( + "context" + "encoding/json" + "fmt" + "math" + "strconv" + "strings" + "unicode" + + "github.com/initializ/forge/forge-core/tools" +) + +type mathCalculateTool struct{} + +type mathCalculateInput struct { + Expression string `json:"expression"` +} + +func (t *mathCalculateTool) Name() string { return "math_calculate" } +func (t *mathCalculateTool) Description() string { return "Evaluate arithmetic expressions safely" } +func (t *mathCalculateTool) Category() tools.Category { return tools.CategoryBuiltin } + +func (t *mathCalculateTool) InputSchema() json.RawMessage { + return json.RawMessage(`{ + "type": "object", + "properties": { + "expression": {"type": "string", "description": "Mathematical expression (e.g. '2 + 3 * 4', 'sqrt(16)', 'pow(2,10)')"} + }, + "required": ["expression"] + }`) +} + +func (t *mathCalculateTool) Execute(ctx context.Context, args json.RawMessage) (string, error) { + var input mathCalculateInput + if err := json.Unmarshal(args, &input); err != nil { + return "", fmt.Errorf("parsing input: %w", err) + } + + result, err := evalExpr(input.Expression) + if err != nil { + return "", err + } + + // Format nicely: if it's a whole number, show without decimal + if result == math.Trunc(result) && !math.IsInf(result, 0) { + return strconv.FormatInt(int64(result), 10), nil + } + return strconv.FormatFloat(result, 'g', -1, 64), nil +} + +// Simple recursive descent parser for arithmetic expressions. +// Supports: +, -, *, /, parentheses, sqrt(), pow(), abs(), unary minus +type parser struct { + input string + pos int +} + +func evalExpr(expr string) (float64, error) { + p := &parser{input: strings.TrimSpace(expr)} + result, err := p.parseExpression() + if err != nil { + return 0, err + } + p.skipSpaces() + if p.pos < len(p.input) { + return 0, fmt.Errorf("unexpected character at position %d: %q", p.pos, string(p.input[p.pos])) + } + return result, nil +} + +func (p *parser) parseExpression() (float64, error) { + return p.parseAddSub() +} + +func (p *parser) parseAddSub() (float64, error) { + left, err := p.parseMulDiv() + if err != nil { + return 0, err + } + + for { + p.skipSpaces() + if p.pos >= len(p.input) { + return left, nil + } + op := p.input[p.pos] + if op != '+' && op != '-' { + return left, nil + } + p.pos++ + right, err := p.parseMulDiv() + if err != nil { + return 0, err + } + if op == '+' { + left += right + } else { + left -= right + } + } +} + +func (p *parser) parseMulDiv() (float64, error) { + left, err := p.parseUnary() + if err != nil { + return 0, err + } + + for { + p.skipSpaces() + if p.pos >= len(p.input) { + return left, nil + } + op := p.input[p.pos] + if op != '*' && op != '/' { + return left, nil + } + p.pos++ + right, err := p.parseUnary() + if err != nil { + return 0, err + } + if op == '*' { + left *= right + } else { + if right == 0 { + return 0, fmt.Errorf("division by zero") + } + left /= right + } + } +} + +func (p *parser) parseUnary() (float64, error) { + p.skipSpaces() + if p.pos < len(p.input) && p.input[p.pos] == '-' { + p.pos++ + val, err := p.parsePrimary() + if err != nil { + return 0, err + } + return -val, nil + } + return p.parsePrimary() +} + +func (p *parser) parsePrimary() (float64, error) { + p.skipSpaces() + if p.pos >= len(p.input) { + return 0, fmt.Errorf("unexpected end of expression") + } + + // Parenthesized expression + if p.input[p.pos] == '(' { + p.pos++ + val, err := p.parseExpression() + if err != nil { + return 0, err + } + p.skipSpaces() + if p.pos >= len(p.input) || p.input[p.pos] != ')' { + return 0, fmt.Errorf("missing closing parenthesis") + } + p.pos++ + return val, nil + } + + // Function call + if unicode.IsLetter(rune(p.input[p.pos])) { + return p.parseFunction() + } + + // Number + return p.parseNumber() +} + +func (p *parser) parseFunction() (float64, error) { + start := p.pos + for p.pos < len(p.input) && (unicode.IsLetter(rune(p.input[p.pos])) || unicode.IsDigit(rune(p.input[p.pos]))) { + p.pos++ + } + name := strings.ToLower(p.input[start:p.pos]) + p.skipSpaces() + + if p.pos >= len(p.input) || p.input[p.pos] != '(' { + return 0, fmt.Errorf("expected '(' after function %q", name) + } + p.pos++ // skip '(' + + args, err := p.parseFuncArgs() + if err != nil { + return 0, err + } + + switch name { + case "sqrt": + if len(args) != 1 { + return 0, fmt.Errorf("sqrt requires 1 argument") + } + return math.Sqrt(args[0]), nil + case "pow": + if len(args) != 2 { + return 0, fmt.Errorf("pow requires 2 arguments") + } + return math.Pow(args[0], args[1]), nil + case "abs": + if len(args) != 1 { + return 0, fmt.Errorf("abs requires 1 argument") + } + return math.Abs(args[0]), nil + case "min": + if len(args) != 2 { + return 0, fmt.Errorf("min requires 2 arguments") + } + return math.Min(args[0], args[1]), nil + case "max": + if len(args) != 2 { + return 0, fmt.Errorf("max requires 2 arguments") + } + return math.Max(args[0], args[1]), nil + default: + return 0, fmt.Errorf("unknown function: %q", name) + } +} + +func (p *parser) parseFuncArgs() ([]float64, error) { + var args []float64 + p.skipSpaces() + if p.pos < len(p.input) && p.input[p.pos] == ')' { + p.pos++ + return args, nil + } + + for { + val, err := p.parseExpression() + if err != nil { + return nil, err + } + args = append(args, val) + p.skipSpaces() + if p.pos >= len(p.input) { + return nil, fmt.Errorf("missing closing parenthesis in function call") + } + if p.input[p.pos] == ')' { + p.pos++ + return args, nil + } + if p.input[p.pos] == ',' { + p.pos++ + continue + } + return nil, fmt.Errorf("unexpected character in function args: %q", string(p.input[p.pos])) + } +} + +func (p *parser) parseNumber() (float64, error) { + p.skipSpaces() + start := p.pos + for p.pos < len(p.input) && (unicode.IsDigit(rune(p.input[p.pos])) || p.input[p.pos] == '.') { + p.pos++ + } + if start == p.pos { + return 0, fmt.Errorf("expected number at position %d", p.pos) + } + return strconv.ParseFloat(p.input[start:p.pos], 64) +} + +func (p *parser) skipSpaces() { + for p.pos < len(p.input) && p.input[p.pos] == ' ' { + p.pos++ + } +} diff --git a/forge-core/tools/builtins/register.go b/forge-core/tools/builtins/register.go new file mode 100644 index 0000000..fafae62 --- /dev/null +++ b/forge-core/tools/builtins/register.go @@ -0,0 +1,36 @@ +package builtins + +import "github.com/initializ/forge/forge-core/tools" + +// All returns all built-in tools. +func All() []tools.Tool { + return []tools.Tool{ + &httpRequestTool{}, + &jsonParseTool{}, + &csvParseTool{}, + &datetimeNowTool{}, + &uuidGenerateTool{}, + &mathCalculateTool{}, + &webSearchTool{}, + } +} + +// RegisterAll registers all built-in tools with the given registry. +func RegisterAll(reg *tools.Registry) error { + for _, t := range All() { + if err := reg.Register(t); err != nil { + return err + } + } + return nil +} + +// GetByName returns a built-in tool by name, or nil if not found. +func GetByName(name string) tools.Tool { + for _, t := range All() { + if t.Name() == name { + return t + } + } + return nil +} diff --git a/forge-core/tools/builtins/uuid_generate.go b/forge-core/tools/builtins/uuid_generate.go new file mode 100644 index 0000000..7975ea8 --- /dev/null +++ b/forge-core/tools/builtins/uuid_generate.go @@ -0,0 +1,35 @@ +package builtins + +import ( + "context" + "crypto/rand" + "encoding/json" + "fmt" + + "github.com/initializ/forge/forge-core/tools" +) + +type uuidGenerateTool struct{} + +func (t *uuidGenerateTool) Name() string { return "uuid_generate" } +func (t *uuidGenerateTool) Description() string { return "Generate a random UUID v4" } +func (t *uuidGenerateTool) Category() tools.Category { return tools.CategoryBuiltin } + +func (t *uuidGenerateTool) InputSchema() json.RawMessage { + return json.RawMessage(`{"type": "object", "properties": {}}`) +} + +func (t *uuidGenerateTool) Execute(ctx context.Context, args json.RawMessage) (string, error) { + var uuid [16]byte + if _, err := rand.Read(uuid[:]); err != nil { + return "", fmt.Errorf("generating UUID: %w", err) + } + + // Set version 4 bits + uuid[6] = (uuid[6] & 0x0f) | 0x40 + // Set variant bits + uuid[8] = (uuid[8] & 0x3f) | 0x80 + + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", + uuid[0:4], uuid[4:6], uuid[6:8], uuid[8:10], uuid[10:16]), nil +} diff --git a/forge-core/tools/builtins/web_search.go b/forge-core/tools/builtins/web_search.go new file mode 100644 index 0000000..fde0f68 --- /dev/null +++ b/forge-core/tools/builtins/web_search.go @@ -0,0 +1,112 @@ +package builtins + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + + "github.com/initializ/forge/forge-core/tools" +) + +type webSearchTool struct{} + +func (t *webSearchTool) Name() string { return "web_search" } +func (t *webSearchTool) Description() string { return "Search the web using Perplexity AI" } +func (t *webSearchTool) Category() tools.Category { return tools.CategoryBuiltin } + +func (t *webSearchTool) InputSchema() json.RawMessage { + return json.RawMessage(`{ + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search query"}, + "max_results": {"type": "integer", "description": "Maximum number of results (default 5)"} + }, + "required": ["query"] + }`) +} + +type webSearchInput struct { + Query string `json:"query"` + MaxResults int `json:"max_results,omitempty"` +} + +func (t *webSearchTool) Execute(ctx context.Context, args json.RawMessage) (string, error) { + apiKey := os.Getenv("PERPLEXITY_API_KEY") + if apiKey == "" { + return `{"error": "PERPLEXITY_API_KEY is not set. Add it to your .env file to enable web search."}`, nil + } + + var input webSearchInput + if err := json.Unmarshal(args, &input); err != nil { + return "", fmt.Errorf("parsing web_search input: %w", err) + } + if input.Query == "" { + return `{"error": "query is required"}`, nil + } + + // Build Perplexity chat completion request + reqBody := map[string]any{ + "model": "sonar", + "messages": []map[string]string{ + {"role": "user", "content": input.Query}, + }, + } + bodyBytes, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("marshalling request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.perplexity.ai/chat/completions", bytes.NewReader(bodyBytes)) + if err != nil { + return "", fmt.Errorf("creating request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+apiKey) + + resp, err := http.DefaultClient.Do(httpReq) + if err != nil { + return "", fmt.Errorf("calling Perplexity API: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("reading response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return fmt.Sprintf(`{"error": "Perplexity API returned status %d: %s"}`, resp.StatusCode, string(respBody)), nil + } + + // Extract the answer from the response + var pResp struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + Citations []string `json:"citations,omitempty"` + } + if err := json.Unmarshal(respBody, &pResp); err != nil { + return "", fmt.Errorf("parsing Perplexity response: %w", err) + } + + if len(pResp.Choices) == 0 { + return `{"error": "no results from Perplexity"}`, nil + } + + result := map[string]any{ + "query": input.Query, + "answer": pResp.Choices[0].Message.Content, + } + if len(pResp.Citations) > 0 { + result["citations"] = pResp.Citations + } + + out, _ := json.Marshal(result) + return string(out), nil +} diff --git a/forge-core/tools/custom_tool.go b/forge-core/tools/custom_tool.go new file mode 100644 index 0000000..27ed4d4 --- /dev/null +++ b/forge-core/tools/custom_tool.go @@ -0,0 +1,62 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" +) + +// CustomTool wraps a discovered script as a Tool implementation. +// It delegates execution to an injected CommandExecutor rather than +// calling os/exec directly, keeping this package free of OS dependencies. +type CustomTool struct { + name string + language string + entrypoint string + executor CommandExecutor +} + +// NewCustomTool creates a tool wrapper for a discovered script. +// If executor is nil, Execute will return an error. +func NewCustomTool(dt DiscoveredTool, executor CommandExecutor) *CustomTool { + return &CustomTool{ + name: dt.Name, + language: dt.Language, + entrypoint: dt.Entrypoint, + executor: executor, + } +} + +func (t *CustomTool) Name() string { return t.name } +func (t *CustomTool) Description() string { + return fmt.Sprintf("Custom %s tool: %s", t.language, t.name) +} +func (t *CustomTool) Category() Category { return CategoryCustom } + +func (t *CustomTool) InputSchema() json.RawMessage { + return json.RawMessage(`{"type": "object", "properties": {}, "additionalProperties": true}`) +} + +func (t *CustomTool) Execute(ctx context.Context, args json.RawMessage) (string, error) { + if t.executor == nil { + return "", fmt.Errorf("tool %q: no command executor configured", t.name) + } + + runtime, runtimeArgs := t.runtimeCommand() + cmdArgs := append(runtimeArgs, t.entrypoint) + + return t.executor.Run(ctx, runtime, cmdArgs, []byte(args)) +} + +func (t *CustomTool) runtimeCommand() (string, []string) { + switch t.language { + case "python": + return "python3", nil + case "typescript": + return "npx", []string{"ts-node"} + case "javascript": + return "node", nil + default: + return t.entrypoint, nil + } +} diff --git a/forge-core/tools/discovery.go b/forge-core/tools/discovery.go new file mode 100644 index 0000000..93fcd13 --- /dev/null +++ b/forge-core/tools/discovery.go @@ -0,0 +1,77 @@ +package tools + +import ( + "io/fs" + "strings" +) + +// DiscoveredTool represents a tool found via filesystem discovery. +type DiscoveredTool struct { + Name string + Path string + Language string + Entrypoint string +} + +// DiscoverToolsFS scans the given fs.FS for tool scripts/modules. +// It looks for: +// - tool_*.py, tool_*.ts, tool_*.js files +// - */tool.py, */tool.ts, */tool.js subdirectories +func DiscoverToolsFS(fsys fs.FS) []DiscoveredTool { + var discovered []DiscoveredTool + + entries, err := fs.ReadDir(fsys, ".") + if err != nil { + return nil + } + + for _, entry := range entries { + name := entry.Name() + + if entry.IsDir() { + // Look for tool.{py,ts,js} inside subdirectory + for _, ext := range []string{".py", ".ts", ".js"} { + toolFile := name + "/tool" + ext + if _, err := fs.Stat(fsys, toolFile); err == nil { + discovered = append(discovered, DiscoveredTool{ + Name: name, + Path: toolFile, + Language: langFromExt(ext), + Entrypoint: toolFile, + }) + break + } + } + continue + } + + // Look for tool_*.{py,ts,js} files + for _, ext := range []string{".py", ".ts", ".js"} { + if strings.HasPrefix(name, "tool_") && strings.HasSuffix(name, ext) { + toolName := strings.TrimSuffix(strings.TrimPrefix(name, "tool_"), ext) + discovered = append(discovered, DiscoveredTool{ + Name: toolName, + Path: name, + Language: langFromExt(ext), + Entrypoint: name, + }) + break + } + } + } + + return discovered +} + +func langFromExt(ext string) string { + switch ext { + case ".py": + return "python" + case ".ts": + return "typescript" + case ".js": + return "javascript" + default: + return "unknown" + } +} diff --git a/forge-core/tools/executor.go b/forge-core/tools/executor.go new file mode 100644 index 0000000..de7468d --- /dev/null +++ b/forge-core/tools/executor.go @@ -0,0 +1,8 @@ +package tools + +import "context" + +// CommandExecutor abstracts command execution for custom tools. +type CommandExecutor interface { + Run(ctx context.Context, command string, args []string, stdin []byte) (stdout string, err error) +} diff --git a/forge-core/tools/network_policy.go b/forge-core/tools/network_policy.go new file mode 100644 index 0000000..8b8a546 --- /dev/null +++ b/forge-core/tools/network_policy.go @@ -0,0 +1,33 @@ +package tools + +// NetworkPolicy describes network requirements for registered tools. +type NetworkPolicy struct { + AllowedHosts []string `json:"allowed_hosts,omitempty"` + DenyAll bool `json:"deny_all,omitempty"` +} + +// GenerateNetworkPolicy scans registered tools and generates a network policy. +func GenerateNetworkPolicy(reg *Registry) NetworkPolicy { + policy := NetworkPolicy{} + hasNetworkTool := false + + for _, name := range reg.List() { + t := reg.Get(name) + if t == nil { + continue + } + + switch t.Name() { + case "http_request", "webhook_call", "mcp_call", "openapi_call": + hasNetworkTool = true + case "web_search": + hasNetworkTool = true + } + } + + if !hasNetworkTool { + policy.DenyAll = true + } + + return policy +} diff --git a/forge-core/tools/registry.go b/forge-core/tools/registry.go new file mode 100644 index 0000000..439ae04 --- /dev/null +++ b/forge-core/tools/registry.go @@ -0,0 +1,110 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "sync" + + "github.com/initializ/forge/forge-core/llm" +) + +// Registry is a thread-safe tool registry. It implements engine.ToolExecutor +// via Go structural typing -- no direct import of the engine package is needed. +type Registry struct { + mu sync.RWMutex + tools map[string]Tool +} + +// NewRegistry creates an empty tool registry. +func NewRegistry() *Registry { + return &Registry{ + tools: make(map[string]Tool), + } +} + +// Register adds a tool to the registry. Returns an error if a tool with the +// same name is already registered. +func (r *Registry) Register(t Tool) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, exists := r.tools[t.Name()]; exists { + return fmt.Errorf("tool already registered: %q", t.Name()) + } + r.tools[t.Name()] = t + return nil +} + +// Get returns the tool with the given name, or nil if not found. +func (r *Registry) Get(name string) Tool { + r.mu.RLock() + defer r.mu.RUnlock() + return r.tools[name] +} + +// List returns the names of all registered tools, sorted alphabetically. +func (r *Registry) List() []string { + r.mu.RLock() + defer r.mu.RUnlock() + + names := make([]string, 0, len(r.tools)) + for name := range r.tools { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// Execute runs the named tool with the given arguments. +// This method satisfies the engine.ToolExecutor interface. +func (r *Registry) Execute(ctx context.Context, name string, arguments json.RawMessage) (string, error) { + r.mu.RLock() + t, ok := r.tools[name] + r.mu.RUnlock() + + if !ok { + return "", fmt.Errorf("unknown tool: %q", name) + } + return t.Execute(ctx, arguments) +} + +// Filter returns a new Registry containing only tools whose names are in the allowed list. +// This is useful for Command to restrict which tools are available at runtime. +func (r *Registry) Filter(allowed []string) *Registry { + allowSet := make(map[string]bool, len(allowed)) + for _, name := range allowed { + allowSet[name] = true + } + + filtered := NewRegistry() + r.mu.RLock() + defer r.mu.RUnlock() + + for name, tool := range r.tools { + if allowSet[name] { + filtered.tools[name] = tool + } + } + return filtered +} + +// ToolDefinitions returns LLM tool definitions for all registered tools. +// This method satisfies the engine.ToolExecutor interface. +func (r *Registry) ToolDefinitions() []llm.ToolDefinition { + r.mu.RLock() + defer r.mu.RUnlock() + + defs := make([]llm.ToolDefinition, 0, len(r.tools)) + names := make([]string, 0, len(r.tools)) + for name := range r.tools { + names = append(names, name) + } + sort.Strings(names) + + for _, name := range names { + defs = append(defs, ToLLMDefinition(r.tools[name])) + } + return defs +} diff --git a/forge-core/tools/tool.go b/forge-core/tools/tool.go new file mode 100644 index 0000000..141307f --- /dev/null +++ b/forge-core/tools/tool.go @@ -0,0 +1,46 @@ +// Package tools provides the tool plugin system for Forge agents. +// Tools are capabilities that an LLM agent can invoke during execution. +package tools + +import ( + "context" + "encoding/json" + + "github.com/initializ/forge/forge-core/llm" +) + +// Category classifies tools by their source/purpose. +type Category string + +const ( + CategoryBuiltin Category = "builtin" + CategoryAdapter Category = "adapter" + CategoryDev Category = "dev" + CategoryCustom Category = "custom" +) + +// Tool is the interface that all tools must implement. +type Tool interface { + // Name returns the unique tool name. + Name() string + // Description returns a human-readable description of the tool. + Description() string + // Category returns the tool's category. + Category() Category + // InputSchema returns the JSON Schema for the tool's input parameters. + InputSchema() json.RawMessage + // Execute runs the tool with the given JSON arguments. + Execute(ctx context.Context, args json.RawMessage) (string, error) +} + +// ToLLMDefinition converts a Tool to an llm.ToolDefinition for use with LLM APIs. +func ToLLMDefinition(t Tool) llm.ToolDefinition { + return llm.ToolDefinition{ + Type: "function", + Function: llm.FunctionSchema{ + Name: t.Name(), + Description: t.Description(), + Parameters: t.InputSchema(), + }, + } +} diff --git a/forge-core/types/config.go b/forge-core/types/config.go new file mode 100644 index 0000000..d6c6fc9 --- /dev/null +++ b/forge-core/types/config.go @@ -0,0 +1,69 @@ +// Package types holds configuration types for forge.yaml. +package types + +import ( + "fmt" + + "gopkg.in/yaml.v3" +) + +// ForgeConfig represents the top-level forge.yaml configuration. +type ForgeConfig struct { + AgentID string `yaml:"agent_id"` + Version string `yaml:"version"` + Framework string `yaml:"framework"` + Entrypoint string `yaml:"entrypoint"` + Model ModelRef `yaml:"model,omitempty"` + Tools []ToolRef `yaml:"tools,omitempty"` + Channels []string `yaml:"channels,omitempty"` + Registry string `yaml:"registry,omitempty"` + Egress EgressRef `yaml:"egress,omitempty"` + Skills SkillsRef `yaml:"skills,omitempty"` +} + +// EgressRef configures egress security controls. +type EgressRef struct { + Profile string `yaml:"profile,omitempty"` // strict, standard, permissive + Mode string `yaml:"mode,omitempty"` // deny-all, allowlist, dev-open + AllowedDomains []string `yaml:"allowed_domains,omitempty"` + Capabilities []string `yaml:"capabilities,omitempty"` // capability bundles (e.g., "slack", "telegram") +} + +// SkillsRef references a skills definition file. +type SkillsRef struct { + Path string `yaml:"path,omitempty"` // default: "skills.md" +} + +// ModelRef identifies the model an agent uses. +type ModelRef struct { + Provider string `yaml:"provider"` + Name string `yaml:"name"` + Version string `yaml:"version,omitempty"` +} + +// ToolRef is a lightweight reference to a tool in forge.yaml. +type ToolRef struct { + Name string `yaml:"name"` + Type string `yaml:"type,omitempty"` + Config map[string]any `yaml:"config,omitempty"` +} + +// ParseForgeConfig parses raw YAML bytes into a ForgeConfig and validates required fields. +func ParseForgeConfig(data []byte) (*ForgeConfig, error) { + var cfg ForgeConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parsing forge config: %w", err) + } + + if cfg.AgentID == "" { + return nil, fmt.Errorf("forge config: agent_id is required") + } + if cfg.Version == "" { + return nil, fmt.Errorf("forge config: version is required") + } + if cfg.Entrypoint == "" { + return nil, fmt.Errorf("forge config: entrypoint is required") + } + + return &cfg, nil +} diff --git a/forge-core/util/slug.go b/forge-core/util/slug.go new file mode 100644 index 0000000..8ae5b6a --- /dev/null +++ b/forge-core/util/slug.go @@ -0,0 +1,24 @@ +// Package util provides shared utility functions. +package util + +import ( + "regexp" + "strings" +) + +var ( + nonAlphanumHyphen = regexp.MustCompile(`[^a-z0-9-]`) + multipleHyphens = regexp.MustCompile(`-{2,}`) +) + +// Slugify converts a human-readable name into a URL/ID-safe slug. +// It lowercases, replaces spaces with hyphens, strips non-[a-z0-9-], +// collapses multiple hyphens, and trims leading/trailing hyphens. +func Slugify(name string) string { + s := strings.ToLower(strings.TrimSpace(name)) + s = strings.ReplaceAll(s, " ", "-") + s = nonAlphanumHyphen.ReplaceAllString(s, "") + s = multipleHyphens.ReplaceAllString(s, "-") + s = strings.Trim(s, "-") + return s +} diff --git a/forge-core/util/slug_test.go b/forge-core/util/slug_test.go new file mode 100644 index 0000000..883cf22 --- /dev/null +++ b/forge-core/util/slug_test.go @@ -0,0 +1,38 @@ +package util + +import "testing" + +func TestSlugify(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"My Cool Agent", "my-cool-agent"}, + {"hello world", "hello-world"}, + {" Leading Spaces ", "leading-spaces"}, + {"UPPER CASE", "upper-case"}, + {"special!@#$%chars", "specialchars"}, + {"multiple---hyphens", "multiple-hyphens"}, + {"--leading-trailing--", "leading-trailing"}, + {"123-numbers-456", "123-numbers-456"}, + {"", ""}, + {"---", ""}, + {"a", "a"}, + {"Hello World", "hello---world"}, // spaces become hyphens, then collapsed + {"cafĆ©-agent", "caf-agent"}, // non-ascii stripped + {"my_agent_name", "myagentname"}, // underscores stripped + {" --hello--world-- ", "hello-world"}, + } + + // Fix expected: "Hello World" → three spaces → three hyphens → collapsed to one + tests[11].want = "hello-world" + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := Slugify(tt.input) + if got != tt.want { + t.Errorf("Slugify(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} diff --git a/forge-core/validate/command_compat.go b/forge-core/validate/command_compat.go new file mode 100644 index 0000000..6972503 --- /dev/null +++ b/forge-core/validate/command_compat.go @@ -0,0 +1,143 @@ +package validate + +import ( + "encoding/json" + "fmt" + + "github.com/initializ/forge/forge-core/agentspec" +) + +// supportedForgeVersions lists forge_version values accepted by Command. +var supportedForgeVersions = map[string]bool{ + "1.0": true, + "1.1": true, +} + +var supportedToolInterfaceVersions = map[string]bool{"1.0": true} +var supportedSkillsSpecVersions = map[string]bool{"agentskills-v1": true} +var supportedForgeSkillsExtVersions = map[string]bool{"1.0": true} +var knownToolCategories = map[string]bool{"builtin": true, "adapter": true, "dev": true, "custom": true} + +// ValidateCommandCompat checks an AgentSpec against Command platform import +// requirements. It returns errors for hard incompatibilities and warnings for +// missing optional fields. +func ValidateCommandCompat(spec *agentspec.AgentSpec) *ValidationResult { + r := &ValidationResult{} + + // Required fields + if spec.AgentID == "" { + r.Errors = append(r.Errors, "agent_id is required for Command import") + } else if !agentIDPattern.MatchString(spec.AgentID) { + r.Errors = append(r.Errors, fmt.Sprintf("agent_id %q must match ^[a-z0-9-]+$ for Command import", spec.AgentID)) + } + + if spec.Name == "" { + r.Errors = append(r.Errors, "name is required for Command import") + } + + if spec.Version == "" { + r.Errors = append(r.Errors, "version is required for Command import") + } + + if spec.ForgeVersion == "" { + r.Errors = append(r.Errors, "forge_version is required for Command import") + } else if !supportedForgeVersions[spec.ForgeVersion] { + r.Errors = append(r.Errors, fmt.Sprintf("forge_version %q is not supported by Command (supported: 1.0, 1.1)", spec.ForgeVersion)) + } + + // Runtime checks + if spec.Runtime == nil { + r.Errors = append(r.Errors, "runtime is required for Command import") + } else if spec.Runtime.Image == "" { + r.Errors = append(r.Errors, "runtime.image is required for Command import") + } + + // Tool input_schema validation + for i, tool := range spec.Tools { + if len(tool.InputSchema) > 0 { + var js json.RawMessage + if err := json.Unmarshal(tool.InputSchema, &js); err != nil { + r.Errors = append(r.Errors, fmt.Sprintf("tools[%d] (%s): input_schema is not valid JSON", i, tool.Name)) + } + } + } + + // Version field checks + if spec.ToolInterfaceVersion != "" && !supportedToolInterfaceVersions[spec.ToolInterfaceVersion] { + r.Errors = append(r.Errors, fmt.Sprintf("tool_interface_version %q not supported by Command", spec.ToolInterfaceVersion)) + } + if spec.SkillsSpecVersion != "" && !supportedSkillsSpecVersions[spec.SkillsSpecVersion] { + r.Errors = append(r.Errors, fmt.Sprintf("skills_spec_version %q not recognized", spec.SkillsSpecVersion)) + } + if spec.ForgeSkillsExtVersion != "" && !supportedForgeSkillsExtVersions[spec.ForgeSkillsExtVersion] { + r.Errors = append(r.Errors, fmt.Sprintf("forge_skills_ext_version %q not recognized", spec.ForgeSkillsExtVersion)) + } + + // Tool category validation + for i, tool := range spec.Tools { + if tool.Category != "" && !knownToolCategories[tool.Category] { + r.Warnings = append(r.Warnings, fmt.Sprintf("tools[%d] (%s): unknown category %q", i, tool.Name, tool.Category)) + } + // Validate skill_origin references a valid skill + if tool.SkillOrigin != "" && spec.A2A != nil { + found := false + for _, s := range spec.A2A.Skills { + if s.ID == tool.SkillOrigin { + found = true + break + } + } + if !found { + r.Warnings = append(r.Warnings, fmt.Sprintf("tools[%d] (%s): skill_origin %q not found in a2a.skills", i, tool.Name, tool.SkillOrigin)) + } + } + } + + // Skill validation + if spec.A2A != nil { + for i, skill := range spec.A2A.Skills { + if !agentIDPattern.MatchString(skill.ID) { + r.Errors = append(r.Errors, fmt.Sprintf("a2a.skills[%d].id %q must match ^[a-z0-9-]+$", i, skill.ID)) + } + if skill.Description == "" { + r.Warnings = append(r.Warnings, fmt.Sprintf("a2a.skills[%d] (%s): missing description", i, skill.Name)) + } + } + if len(spec.A2A.Skills) > 20 { + r.Warnings = append(r.Warnings, fmt.Sprintf("a2a.skills has %d entries; >20 may cause context window issues in Command", len(spec.A2A.Skills))) + } + } + + // Egress validation + if spec.EgressMode == "allowlist" && spec.EgressProfile == "" { + r.Warnings = append(r.Warnings, "egress_mode is 'allowlist' but egress_profile is empty") + } + + // Warn if tool_interface_version not set + if spec.ToolInterfaceVersion == "" { + r.Warnings = append(r.Warnings, "tool_interface_version not set; Command may use default behavior") + } + + // Warnings for optional but recommended fields + if spec.PolicyScaffold != nil { + for _, g := range spec.PolicyScaffold.Guardrails { + if !knownGuardrailTypes[g.Type] { + r.Warnings = append(r.Warnings, fmt.Sprintf("unknown guardrail type %q may not be supported by Command", g.Type)) + } + } + } + + if spec.A2A == nil { + r.Warnings = append(r.Warnings, "a2a config is not set; agent will have no A2A capabilities in Command") + } else if spec.A2A.Capabilities == nil { + r.Warnings = append(r.Warnings, "a2a.capabilities is not set; agent capabilities will default to false in Command") + } + + if spec.Model == nil { + r.Warnings = append(r.Warnings, "model config is not set; no model will be configured in Command") + } else if spec.Model.Provider == "" { + r.Warnings = append(r.Warnings, "model.provider is empty; model configuration may be incomplete in Command") + } + + return r +} diff --git a/forge-core/validate/command_compat_test.go b/forge-core/validate/command_compat_test.go new file mode 100644 index 0000000..76e9c43 --- /dev/null +++ b/forge-core/validate/command_compat_test.go @@ -0,0 +1,477 @@ +package validate + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/initializ/forge/forge-core/agentspec" +) + +func validAgentSpec() *agentspec.AgentSpec { + return &agentspec.AgentSpec{ + ForgeVersion: "1.0", + AgentID: "test-agent", + Version: "0.1.0", + Name: "Test Agent", + ToolInterfaceVersion: "1.0", + Runtime: &agentspec.RuntimeConfig{ + Image: "python:3.11-slim", + Port: 8080, + }, + Tools: []agentspec.ToolSpec{ + { + Name: "web-search", + Description: "Search the web", + InputSchema: json.RawMessage(`{"type":"object","properties":{"query":{"type":"string"}}}`), + }, + }, + PolicyScaffold: &agentspec.PolicyScaffold{ + Guardrails: []agentspec.Guardrail{ + {Type: "content_filter"}, + }, + }, + A2A: &agentspec.A2AConfig{ + Capabilities: &agentspec.A2ACapabilities{ + Streaming: true, + }, + }, + Model: &agentspec.ModelConfig{ + Provider: "openai", + Name: "gpt-4", + }, + } +} + +func TestValidateCommandCompat_ValidSpec(t *testing.T) { + r := ValidateCommandCompat(validAgentSpec()) + if !r.IsValid() { + t.Fatalf("expected valid, got errors: %v", r.Errors) + } + if len(r.Warnings) != 0 { + t.Fatalf("expected no warnings, got: %v", r.Warnings) + } +} + +func TestValidateCommandCompat_InvalidAgentID(t *testing.T) { + spec := validAgentSpec() + spec.AgentID = "INVALID_ID!" + r := ValidateCommandCompat(spec) + if r.IsValid() { + t.Fatal("expected invalid for bad agent_id") + } + found := false + for _, e := range r.Errors { + if contains(e, "agent_id") { + found = true + } + } + if !found { + t.Errorf("expected agent_id error, got: %v", r.Errors) + } +} + +func TestValidateCommandCompat_EmptyAgentID(t *testing.T) { + spec := validAgentSpec() + spec.AgentID = "" + r := ValidateCommandCompat(spec) + if r.IsValid() { + t.Fatal("expected invalid for empty agent_id") + } +} + +func TestValidateCommandCompat_UnsupportedForgeVersion(t *testing.T) { + spec := validAgentSpec() + spec.ForgeVersion = "2.0" + r := ValidateCommandCompat(spec) + if r.IsValid() { + t.Fatal("expected invalid for unsupported forge_version") + } + found := false + for _, e := range r.Errors { + if contains(e, "forge_version") { + found = true + } + } + if !found { + t.Errorf("expected forge_version error, got: %v", r.Errors) + } +} + +func TestValidateCommandCompat_ForgeVersion11(t *testing.T) { + spec := validAgentSpec() + spec.ForgeVersion = "1.1" + r := ValidateCommandCompat(spec) + if !r.IsValid() { + t.Fatalf("forge_version 1.1 should be valid, got errors: %v", r.Errors) + } +} + +func TestValidateCommandCompat_MissingRuntime(t *testing.T) { + spec := validAgentSpec() + spec.Runtime = nil + r := ValidateCommandCompat(spec) + if r.IsValid() { + t.Fatal("expected invalid for nil runtime") + } +} + +func TestValidateCommandCompat_EmptyRuntimeImage(t *testing.T) { + spec := validAgentSpec() + spec.Runtime.Image = "" + r := ValidateCommandCompat(spec) + if r.IsValid() { + t.Fatal("expected invalid for empty runtime.image") + } +} + +func TestValidateCommandCompat_UnknownGuardrails(t *testing.T) { + spec := validAgentSpec() + spec.PolicyScaffold = &agentspec.PolicyScaffold{ + Guardrails: []agentspec.Guardrail{ + {Type: "content_filter"}, + {Type: "custom_unknown_type"}, + }, + } + r := ValidateCommandCompat(spec) + if !r.IsValid() { + t.Fatalf("unknown guardrails should produce warnings not errors, got errors: %v", r.Errors) + } + if len(r.Warnings) == 0 { + t.Fatal("expected warnings for unknown guardrail type") + } +} + +func TestValidateCommandCompat_MissingA2A(t *testing.T) { + spec := validAgentSpec() + spec.A2A = nil + r := ValidateCommandCompat(spec) + if !r.IsValid() { + t.Fatalf("missing a2a should produce warnings not errors, got errors: %v", r.Errors) + } + found := false + for _, w := range r.Warnings { + if contains(w, "a2a") { + found = true + } + } + if !found { + t.Errorf("expected a2a warning, got: %v", r.Warnings) + } +} + +func TestValidateCommandCompat_MissingA2ACapabilities(t *testing.T) { + spec := validAgentSpec() + spec.A2A = &agentspec.A2AConfig{} + r := ValidateCommandCompat(spec) + if !r.IsValid() { + t.Fatalf("missing capabilities should produce warnings not errors, got errors: %v", r.Errors) + } + found := false + for _, w := range r.Warnings { + if contains(w, "capabilities") { + found = true + } + } + if !found { + t.Errorf("expected capabilities warning, got: %v", r.Warnings) + } +} + +func TestValidateCommandCompat_MissingModel(t *testing.T) { + spec := validAgentSpec() + spec.Model = nil + r := ValidateCommandCompat(spec) + if !r.IsValid() { + t.Fatalf("missing model should produce warnings not errors, got errors: %v", r.Errors) + } + found := false + for _, w := range r.Warnings { + if contains(w, "model") { + found = true + } + } + if !found { + t.Errorf("expected model warning, got: %v", r.Warnings) + } +} + +func TestValidateCommandCompat_EmptyModelProvider(t *testing.T) { + spec := validAgentSpec() + spec.Model = &agentspec.ModelConfig{Name: "gpt-4"} + r := ValidateCommandCompat(spec) + if !r.IsValid() { + t.Fatalf("empty model.provider should produce warnings not errors, got errors: %v", r.Errors) + } + found := false + for _, w := range r.Warnings { + if contains(w, "model.provider") { + found = true + } + } + if !found { + t.Errorf("expected model.provider warning, got: %v", r.Warnings) + } +} + +func TestValidateCommandCompat_InvalidToolSchema(t *testing.T) { + spec := validAgentSpec() + spec.Tools = []agentspec.ToolSpec{ + { + Name: "bad-tool", + InputSchema: json.RawMessage(`{not valid json`), + }, + } + r := ValidateCommandCompat(spec) + if r.IsValid() { + t.Fatal("expected invalid for bad tool input_schema") + } +} + +func TestValidateCommandCompat_MultipleIssues(t *testing.T) { + spec := &agentspec.AgentSpec{ + ForgeVersion: "3.0", + AgentID: "INVALID!", + // Name, Version empty + // Runtime nil + } + r := ValidateCommandCompat(spec) + if r.IsValid() { + t.Fatal("expected multiple errors") + } + // Should have errors for: agent_id pattern, name, version, forge_version, runtime + if len(r.Errors) < 4 { + t.Errorf("expected at least 4 errors, got %d: %v", len(r.Errors), r.Errors) + } +} + +// contains checks if substr is in s (case-insensitive-ish, just substring match). +func contains(s, substr string) bool { + return len(s) >= len(substr) && containsStr(s, substr) +} + +func containsStr(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} + +func TestValidateCommandCompat_ToolInterfaceVersion(t *testing.T) { + // Valid version + spec := validAgentSpec() + spec.ToolInterfaceVersion = "1.0" + r := ValidateCommandCompat(spec) + if !r.IsValid() { + t.Fatalf("expected valid for tool_interface_version 1.0, got errors: %v", r.Errors) + } + + // Unsupported version + spec.ToolInterfaceVersion = "2.0" + r = ValidateCommandCompat(spec) + if r.IsValid() { + t.Fatal("expected invalid for unsupported tool_interface_version") + } + found := false + for _, e := range r.Errors { + if contains(e, "tool_interface_version") { + found = true + } + } + if !found { + t.Errorf("expected tool_interface_version error, got: %v", r.Errors) + } +} + +func TestValidateCommandCompat_SkillsSpecVersion(t *testing.T) { + // Valid version + spec := validAgentSpec() + spec.SkillsSpecVersion = "agentskills-v1" + r := ValidateCommandCompat(spec) + if !r.IsValid() { + t.Fatalf("expected valid for skills_spec_version agentskills-v1, got errors: %v", r.Errors) + } + + // Unrecognized version + spec.SkillsSpecVersion = "unknown-v2" + r = ValidateCommandCompat(spec) + if r.IsValid() { + t.Fatal("expected invalid for unrecognized skills_spec_version") + } + found := false + for _, e := range r.Errors { + if contains(e, "skills_spec_version") { + found = true + } + } + if !found { + t.Errorf("expected skills_spec_version error, got: %v", r.Errors) + } +} + +func TestValidateCommandCompat_ToolCategories(t *testing.T) { + spec := validAgentSpec() + // Known category — no warning + spec.Tools = []agentspec.ToolSpec{ + {Name: "t1", Category: "builtin", InputSchema: json.RawMessage(`{}`)}, + } + r := ValidateCommandCompat(spec) + if !r.IsValid() { + t.Fatalf("expected valid, got errors: %v", r.Errors) + } + for _, w := range r.Warnings { + if contains(w, "unknown category") { + t.Errorf("unexpected unknown category warning: %s", w) + } + } + + // Unknown category — warning + spec.Tools = []agentspec.ToolSpec{ + {Name: "t2", Category: "experimental", InputSchema: json.RawMessage(`{}`)}, + } + r = ValidateCommandCompat(spec) + found := false + for _, w := range r.Warnings { + if contains(w, "unknown category") && contains(w, "experimental") { + found = true + } + } + if !found { + t.Errorf("expected unknown category warning for 'experimental', got: %v", r.Warnings) + } +} + +func TestValidateCommandCompat_SkillOriginValidation(t *testing.T) { + spec := validAgentSpec() + spec.A2A = &agentspec.A2AConfig{ + Skills: []agentspec.A2ASkill{ + {ID: "pdf-processing", Name: "PDF Processing", Description: "Processes PDFs"}, + }, + Capabilities: &agentspec.A2ACapabilities{Streaming: true}, + } + + // Valid reference + spec.Tools = []agentspec.ToolSpec{ + {Name: "pdf-tool", SkillOrigin: "pdf-processing", InputSchema: json.RawMessage(`{}`)}, + } + r := ValidateCommandCompat(spec) + if !r.IsValid() { + t.Fatalf("expected valid, got errors: %v", r.Errors) + } + for _, w := range r.Warnings { + if contains(w, "skill_origin") && contains(w, "not found") { + t.Errorf("unexpected dangling skill_origin warning: %s", w) + } + } + + // Dangling reference + spec.Tools = []agentspec.ToolSpec{ + {Name: "orphan-tool", SkillOrigin: "nonexistent-skill", InputSchema: json.RawMessage(`{}`)}, + } + r = ValidateCommandCompat(spec) + found := false + for _, w := range r.Warnings { + if contains(w, "skill_origin") && contains(w, "not found") { + found = true + } + } + if !found { + t.Errorf("expected dangling skill_origin warning, got: %v", r.Warnings) + } +} + +func TestValidateCommandCompat_SkillIDPattern(t *testing.T) { + spec := validAgentSpec() + spec.A2A = &agentspec.A2AConfig{ + Skills: []agentspec.A2ASkill{ + {ID: "valid-skill", Name: "Valid Skill", Description: "A valid skill"}, + }, + Capabilities: &agentspec.A2ACapabilities{Streaming: true}, + } + r := ValidateCommandCompat(spec) + if !r.IsValid() { + t.Fatalf("expected valid for valid skill ID, got errors: %v", r.Errors) + } + + // Invalid skill ID + spec.A2A.Skills = []agentspec.A2ASkill{ + {ID: "INVALID_SKILL!", Name: "Bad Skill", Description: "desc"}, + } + r = ValidateCommandCompat(spec) + if r.IsValid() { + t.Fatal("expected invalid for bad skill ID") + } + found := false + for _, e := range r.Errors { + if contains(e, "a2a.skills") && contains(e, "must match") { + found = true + } + } + if !found { + t.Errorf("expected skill ID pattern error, got: %v", r.Errors) + } +} + +func TestValidateCommandCompat_SkillDescriptionWarning(t *testing.T) { + spec := validAgentSpec() + spec.A2A = &agentspec.A2AConfig{ + Skills: []agentspec.A2ASkill{ + {ID: "no-desc", Name: "No Desc Skill"}, + }, + Capabilities: &agentspec.A2ACapabilities{Streaming: true}, + } + r := ValidateCommandCompat(spec) + found := false + for _, w := range r.Warnings { + if contains(w, "missing description") { + found = true + } + } + if !found { + t.Errorf("expected missing description warning, got: %v", r.Warnings) + } +} + +func TestValidateCommandCompat_TooManySkills(t *testing.T) { + spec := validAgentSpec() + skills := make([]agentspec.A2ASkill, 21) + for i := range skills { + skills[i] = agentspec.A2ASkill{ + ID: fmt.Sprintf("skill-%d", i), + Name: fmt.Sprintf("Skill %d", i), + Description: "A skill", + } + } + spec.A2A = &agentspec.A2AConfig{ + Skills: skills, + Capabilities: &agentspec.A2ACapabilities{Streaming: true}, + } + r := ValidateCommandCompat(spec) + found := false + for _, w := range r.Warnings { + if contains(w, ">20") { + found = true + } + } + if !found { + t.Errorf("expected >20 skills warning, got: %v", r.Warnings) + } +} + +func TestValidateCommandCompat_EgressModeWarning(t *testing.T) { + spec := validAgentSpec() + spec.EgressMode = "allowlist" + spec.EgressProfile = "" + r := ValidateCommandCompat(spec) + found := false + for _, w := range r.Warnings { + if contains(w, "egress_mode") && contains(w, "allowlist") && contains(w, "egress_profile") { + found = true + } + } + if !found { + t.Errorf("expected egress_mode/profile warning, got: %v", r.Warnings) + } +} diff --git a/forge-core/validate/forge_config.go b/forge-core/validate/forge_config.go new file mode 100644 index 0000000..3a20ee9 --- /dev/null +++ b/forge-core/validate/forge_config.go @@ -0,0 +1,83 @@ +package validate + +import ( + "fmt" + "regexp" + + "github.com/initializ/forge/forge-core/types" +) + +var ( + agentIDPattern = regexp.MustCompile(`^[a-z0-9-]+$`) + semverPattern = regexp.MustCompile(`^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$`) + + knownFrameworks = map[string]bool{"crewai": true, "langchain": true, "custom": true} + knownEgressProfiles = map[string]bool{"strict": true, "standard": true, "permissive": true} + knownEgressModes = map[string]bool{"deny-all": true, "allowlist": true, "dev-open": true} + knownGuardrailTypes = map[string]bool{ + "no_pii": true, + "jailbreak_protection": true, + "tool_scope_enforcement": true, + "output_format_validation": true, + "content_filter": true, + } +) + +// ValidationResult holds errors and warnings from config validation. +type ValidationResult struct { + Errors []string + Warnings []string +} + +// IsValid returns true if there are no validation errors. +func (r *ValidationResult) IsValid() bool { + return len(r.Errors) == 0 +} + +// ValidateForgeConfig checks a ForgeConfig for errors and warnings. +func ValidateForgeConfig(cfg *types.ForgeConfig) *ValidationResult { + r := &ValidationResult{} + + if cfg.AgentID == "" { + r.Errors = append(r.Errors, "agent_id is required") + } else if !agentIDPattern.MatchString(cfg.AgentID) { + r.Errors = append(r.Errors, fmt.Sprintf("agent_id %q must match ^[a-z0-9-]+$", cfg.AgentID)) + } + + if cfg.Version == "" { + r.Errors = append(r.Errors, "version is required") + } else if !semverPattern.MatchString(cfg.Version) { + r.Errors = append(r.Errors, fmt.Sprintf("version %q is not valid semver", cfg.Version)) + } + + if cfg.Entrypoint == "" { + r.Errors = append(r.Errors, "entrypoint is required") + } + + for i, t := range cfg.Tools { + if t.Name == "" { + r.Errors = append(r.Errors, fmt.Sprintf("tools[%d]: name is required", i)) + } + } + + if cfg.Model.Provider != "" && cfg.Model.Name == "" { + r.Warnings = append(r.Warnings, "model.provider is set but model.name is empty") + } + + if cfg.Framework != "" && !knownFrameworks[cfg.Framework] { + r.Warnings = append(r.Warnings, fmt.Sprintf("unknown framework %q (known: crewai, langchain, custom)", cfg.Framework)) + } + + // Validate egress config + if cfg.Egress.Profile != "" && !knownEgressProfiles[cfg.Egress.Profile] { + r.Errors = append(r.Errors, fmt.Sprintf("egress.profile %q must be one of: strict, standard, permissive", cfg.Egress.Profile)) + } + if cfg.Egress.Mode != "" && !knownEgressModes[cfg.Egress.Mode] { + r.Errors = append(r.Errors, fmt.Sprintf("egress.mode %q must be one of: deny-all, allowlist, dev-open", cfg.Egress.Mode)) + } + if cfg.Egress.Mode == "dev-open" { + r.Warnings = append(r.Warnings, "egress mode 'dev-open' is not recommended for production") + } + + return r +} diff --git a/forge-core/validate/forge_config_test.go b/forge-core/validate/forge_config_test.go new file mode 100644 index 0000000..2a962a5 --- /dev/null +++ b/forge-core/validate/forge_config_test.go @@ -0,0 +1,105 @@ +package validate + +import ( + "testing" + + "github.com/initializ/forge/forge-core/types" +) + +func validConfig() *types.ForgeConfig { + return &types.ForgeConfig{ + AgentID: "my-agent", + Version: "0.1.0", + Framework: "langchain", + Entrypoint: "python agent.py", + Model: types.ModelRef{ + Provider: "openai", + Name: "gpt-4", + }, + Tools: []types.ToolRef{ + {Name: "web-search", Type: "builtin"}, + }, + } +} + +func TestValidateForgeConfig_Valid(t *testing.T) { + r := ValidateForgeConfig(validConfig()) + if !r.IsValid() { + t.Fatalf("expected valid, got errors: %v", r.Errors) + } + if len(r.Warnings) != 0 { + t.Fatalf("expected no warnings, got: %v", r.Warnings) + } +} + +func TestValidateForgeConfig_InvalidAgentID(t *testing.T) { + cfg := validConfig() + cfg.AgentID = "My_Agent!" + r := ValidateForgeConfig(cfg) + if r.IsValid() { + t.Fatal("expected invalid") + } + if len(r.Errors) != 1 { + t.Fatalf("expected 1 error, got %d: %v", len(r.Errors), r.Errors) + } +} + +func TestValidateForgeConfig_EmptyAgentID(t *testing.T) { + cfg := validConfig() + cfg.AgentID = "" + r := ValidateForgeConfig(cfg) + if r.IsValid() { + t.Fatal("expected invalid") + } +} + +func TestValidateForgeConfig_BadSemver(t *testing.T) { + cfg := validConfig() + cfg.Version = "v1.0" + r := ValidateForgeConfig(cfg) + if r.IsValid() { + t.Fatal("expected invalid") + } +} + +func TestValidateForgeConfig_EmptyEntrypoint(t *testing.T) { + cfg := validConfig() + cfg.Entrypoint = "" + r := ValidateForgeConfig(cfg) + if r.IsValid() { + t.Fatal("expected invalid") + } +} + +func TestValidateForgeConfig_EmptyToolName(t *testing.T) { + cfg := validConfig() + cfg.Tools = []types.ToolRef{{Name: ""}} + r := ValidateForgeConfig(cfg) + if r.IsValid() { + t.Fatal("expected invalid") + } +} + +func TestValidateForgeConfig_ProviderWithoutName(t *testing.T) { + cfg := validConfig() + cfg.Model = types.ModelRef{Provider: "openai", Name: ""} + r := ValidateForgeConfig(cfg) + if !r.IsValid() { + t.Fatalf("expected valid, got errors: %v", r.Errors) + } + if len(r.Warnings) != 1 { + t.Fatalf("expected 1 warning, got %d: %v", len(r.Warnings), r.Warnings) + } +} + +func TestValidateForgeConfig_UnknownFramework(t *testing.T) { + cfg := validConfig() + cfg.Framework = "autogen" + r := ValidateForgeConfig(cfg) + if !r.IsValid() { + t.Fatalf("expected valid, got errors: %v", r.Errors) + } + if len(r.Warnings) != 1 { + t.Fatalf("expected 1 warning, got %d: %v", len(r.Warnings), r.Warnings) + } +} diff --git a/forge-core/validate/import_sim.go b/forge-core/validate/import_sim.go new file mode 100644 index 0000000..bddd001 --- /dev/null +++ b/forge-core/validate/import_sim.go @@ -0,0 +1,150 @@ +package validate + +import ( + "fmt" + + "github.com/initializ/forge/forge-core/agentspec" +) + +// AgentDefinition represents what Command's import API produces from an AgentSpec. +type AgentDefinition struct { + Slug string `json:"slug"` + DisplayName string `json:"display_name"` + Description string `json:"description,omitempty"` + ContainerImage string `json:"container_image,omitempty"` + Port int `json:"port,omitempty"` + EnvVars map[string]string `json:"env_vars,omitempty"` + Tools []ImportedTool `json:"tools,omitempty"` + ModelProvider string `json:"model_provider,omitempty"` + ModelName string `json:"model_name,omitempty"` + Capabilities *A2ACaps `json:"capabilities,omitempty"` + Guardrails []string `json:"guardrails,omitempty"` + ToolInterfaceVersion string `json:"tool_interface_version,omitempty"` + SkillsSpecVersion string `json:"skills_spec_version,omitempty"` + EgressProfile string `json:"egress_profile,omitempty"` + EgressMode string `json:"egress_mode,omitempty"` + Skills []string `json:"skills,omitempty"` +} + +// ImportedTool represents a tool as imported by Command. +type ImportedTool struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + HasSchema bool `json:"has_schema"` + Category string `json:"category,omitempty"` + SkillOrigin string `json:"skill_origin,omitempty"` +} + +// A2ACaps represents agent capabilities as understood by Command. +type A2ACaps struct { + Streaming bool `json:"streaming"` + PushNotifications bool `json:"push_notifications"` +} + +// ImportSimResult holds the simulated import output. +type ImportSimResult struct { + Definition *AgentDefinition `json:"agent_definition"` + ImportWarnings []string `json:"import_warnings"` +} + +// SimulateImport simulates what Command's POST /api/v1/agents/import would +// produce from the given AgentSpec. +func SimulateImport(spec *agentspec.AgentSpec) *ImportSimResult { + result := &ImportSimResult{ + Definition: &AgentDefinition{ + Slug: spec.AgentID, + DisplayName: spec.Name, + Description: spec.Description, + }, + } + + // Runtime mapping + if spec.Runtime != nil { + result.Definition.ContainerImage = spec.Runtime.Image + result.Definition.Port = spec.Runtime.Port + if len(spec.Runtime.Env) > 0 { + result.Definition.EnvVars = spec.Runtime.Env + } + } else { + result.ImportWarnings = append(result.ImportWarnings, "no runtime config; container image will not be set") + } + + // Version fields mapping + result.Definition.ToolInterfaceVersion = spec.ToolInterfaceVersion + result.Definition.SkillsSpecVersion = spec.SkillsSpecVersion + result.Definition.EgressProfile = spec.EgressProfile + result.Definition.EgressMode = spec.EgressMode + + // Skills mapping + if spec.A2A != nil { + for _, skill := range spec.A2A.Skills { + result.Definition.Skills = append(result.Definition.Skills, skill.ID) + } + } + + // Tools mapping + for i, tool := range spec.Tools { + result.Definition.Tools = append(result.Definition.Tools, ImportedTool{ + Name: tool.Name, + Description: tool.Description, + HasSchema: len(tool.InputSchema) > 0, + Category: tool.Category, + SkillOrigin: tool.SkillOrigin, + }) + if tool.Category != "" { + result.ImportWarnings = append(result.ImportWarnings, fmt.Sprintf("tools[%d].category '%s' mapped — verify in Security step", i, tool.Category)) + } + if tool.SkillOrigin != "" { + result.ImportWarnings = append(result.ImportWarnings, fmt.Sprintf("tools[%d].skill_origin '%s' — skill-bound tool, ensure skill context available", i, tool.SkillOrigin)) + } + } + + // Model mapping + if spec.Model != nil { + result.Definition.ModelProvider = spec.Model.Provider + result.Definition.ModelName = spec.Model.Name + } else { + result.ImportWarnings = append(result.ImportWarnings, "no model config; model will not be configured") + } + + // Capabilities mapping + if spec.A2A != nil && spec.A2A.Capabilities != nil { + result.Definition.Capabilities = &A2ACaps{ + Streaming: spec.A2A.Capabilities.Streaming, + PushNotifications: spec.A2A.Capabilities.PushNotifications, + } + } else { + result.ImportWarnings = append(result.ImportWarnings, "no A2A capabilities; defaults will be used") + } + + // Guardrails mapping + if spec.PolicyScaffold != nil { + for _, g := range spec.PolicyScaffold.Guardrails { + result.Definition.Guardrails = append(result.Definition.Guardrails, g.Type) + if !knownGuardrailTypes[g.Type] { + result.ImportWarnings = append(result.ImportWarnings, + fmt.Sprintf("unknown guardrail type %q may be ignored by Command", g.Type)) + } + } + } + + // Skills warnings + if spec.A2A != nil && len(spec.A2A.Skills) > 0 { + result.ImportWarnings = append(result.ImportWarnings, fmt.Sprintf("a2a.skills contains %d skills — verify skill activation behavior in test sandbox", len(spec.A2A.Skills))) + } + + // Egress warnings + if spec.EgressProfile != "" { + result.ImportWarnings = append(result.ImportWarnings, fmt.Sprintf("security.egress.profile '%s' is Forge baseline — Command will apply org-level policy on top", spec.EgressProfile)) + } + if spec.EgressMode != "" { + result.ImportWarnings = append(result.ImportWarnings, "network_policy provided as baseline — Command will generate its own NetworkPolicy per cluster policy") + } + + // Version field warnings + if spec.ToolInterfaceVersion != "" { + result.ImportWarnings = append(result.ImportWarnings, fmt.Sprintf("tool_interface_version '%s' — compatible with Command >=1.0.0", spec.ToolInterfaceVersion)) + } + + return result +} diff --git a/forge-core/validate/import_sim_test.go b/forge-core/validate/import_sim_test.go new file mode 100644 index 0000000..ed043ff --- /dev/null +++ b/forge-core/validate/import_sim_test.go @@ -0,0 +1,308 @@ +package validate + +import ( + "encoding/json" + "testing" + + "github.com/initializ/forge/forge-core/agentspec" +) + +func TestSimulateImport_FullSpec(t *testing.T) { + spec := validAgentSpec() + result := SimulateImport(spec) + + if result.Definition == nil { + t.Fatal("expected non-nil definition") + } + + d := result.Definition + if d.Slug != "test-agent" { + t.Errorf("Slug = %q, want %q", d.Slug, "test-agent") + } + if d.DisplayName != "Test Agent" { + t.Errorf("DisplayName = %q, want %q", d.DisplayName, "Test Agent") + } + if d.ContainerImage != "python:3.11-slim" { + t.Errorf("ContainerImage = %q, want %q", d.ContainerImage, "python:3.11-slim") + } + if d.Port != 8080 { + t.Errorf("Port = %d, want %d", d.Port, 8080) + } + if d.ModelProvider != "openai" { + t.Errorf("ModelProvider = %q, want %q", d.ModelProvider, "openai") + } + if d.ModelName != "gpt-4" { + t.Errorf("ModelName = %q, want %q", d.ModelName, "gpt-4") + } + if d.Capabilities == nil { + t.Fatal("expected non-nil capabilities") + } + if !d.Capabilities.Streaming { + t.Error("expected Streaming = true") + } + // With tool_interface_version set, we expect one version info warning + versionWarnings := 0 + for _, w := range result.ImportWarnings { + if contains(w, "tool_interface_version") { + versionWarnings++ + } + } + if versionWarnings != 1 { + t.Errorf("expected 1 tool_interface_version warning, got %d; all warnings: %v", versionWarnings, result.ImportWarnings) + } +} + +func TestSimulateImport_MinimalSpec(t *testing.T) { + spec := &agentspec.AgentSpec{ + ForgeVersion: "1.0", + AgentID: "minimal", + Version: "0.1.0", + Name: "Minimal Agent", + } + result := SimulateImport(spec) + + d := result.Definition + if d.Slug != "minimal" { + t.Errorf("Slug = %q, want %q", d.Slug, "minimal") + } + if d.DisplayName != "Minimal Agent" { + t.Errorf("DisplayName = %q, want %q", d.DisplayName, "Minimal Agent") + } + if d.ContainerImage != "" { + t.Errorf("ContainerImage should be empty, got %q", d.ContainerImage) + } + + // Should have warnings for missing runtime, model, a2a + if len(result.ImportWarnings) < 3 { + t.Errorf("expected at least 3 warnings for minimal spec, got %d: %v", len(result.ImportWarnings), result.ImportWarnings) + } +} + +func TestSimulateImport_ToolMapping(t *testing.T) { + spec := &agentspec.AgentSpec{ + ForgeVersion: "1.0", + AgentID: "tool-agent", + Version: "0.1.0", + Name: "Tool Agent", + Runtime: &agentspec.RuntimeConfig{Image: "python:3.11"}, + Model: &agentspec.ModelConfig{Provider: "openai", Name: "gpt-4"}, + A2A: &agentspec.A2AConfig{ + Capabilities: &agentspec.A2ACapabilities{}, + }, + Tools: []agentspec.ToolSpec{ + { + Name: "search", + Description: "Search tool", + InputSchema: json.RawMessage(`{"type":"object"}`), + }, + { + Name: "calc", + Description: "Calculator", + }, + }, + } + result := SimulateImport(spec) + + if len(result.Definition.Tools) != 2 { + t.Fatalf("expected 2 tools, got %d", len(result.Definition.Tools)) + } + + if result.Definition.Tools[0].Name != "search" { + t.Errorf("tool[0].Name = %q, want %q", result.Definition.Tools[0].Name, "search") + } + if !result.Definition.Tools[0].HasSchema { + t.Error("tool[0] should have HasSchema=true") + } + + if result.Definition.Tools[1].Name != "calc" { + t.Errorf("tool[1].Name = %q, want %q", result.Definition.Tools[1].Name, "calc") + } + if result.Definition.Tools[1].HasSchema { + t.Error("tool[1] should have HasSchema=false") + } +} + +func TestSimulateImport_GuardrailMapping(t *testing.T) { + spec := validAgentSpec() + spec.PolicyScaffold = &agentspec.PolicyScaffold{ + Guardrails: []agentspec.Guardrail{ + {Type: "content_filter"}, + {Type: "no_pii"}, + {Type: "custom_experimental"}, + }, + } + result := SimulateImport(spec) + + if len(result.Definition.Guardrails) != 3 { + t.Fatalf("expected 3 guardrails, got %d", len(result.Definition.Guardrails)) + } + + // Should have 1 warning for the unknown guardrail + unknownCount := 0 + for _, w := range result.ImportWarnings { + if contains(w, "custom_experimental") { + unknownCount++ + } + } + if unknownCount != 1 { + t.Errorf("expected 1 warning about custom_experimental, got %d", unknownCount) + } +} + +func TestSimulateImport_EnvVars(t *testing.T) { + spec := validAgentSpec() + spec.Runtime.Env = map[string]string{ + "API_KEY": "test", + "DEBUG": "true", + } + result := SimulateImport(spec) + + if len(result.Definition.EnvVars) != 2 { + t.Fatalf("expected 2 env vars, got %d", len(result.Definition.EnvVars)) + } + if result.Definition.EnvVars["API_KEY"] != "test" { + t.Errorf("EnvVars[API_KEY] = %q, want %q", result.Definition.EnvVars["API_KEY"], "test") + } +} + +func TestSimulateImport_ToolCategories(t *testing.T) { + spec := validAgentSpec() + spec.Tools = []agentspec.ToolSpec{ + {Name: "builtin-tool", Category: "builtin", InputSchema: json.RawMessage(`{}`)}, + {Name: "dev-tool", Category: "dev", InputSchema: json.RawMessage(`{}`)}, + } + result := SimulateImport(spec) + + if len(result.Definition.Tools) != 2 { + t.Fatalf("expected 2 tools, got %d", len(result.Definition.Tools)) + } + if result.Definition.Tools[0].Category != "builtin" { + t.Errorf("tool[0].Category = %q, want %q", result.Definition.Tools[0].Category, "builtin") + } + if result.Definition.Tools[1].Category != "dev" { + t.Errorf("tool[1].Category = %q, want %q", result.Definition.Tools[1].Category, "dev") + } + // Should have category mapping warnings + found := 0 + for _, w := range result.ImportWarnings { + if contains(w, "category") && contains(w, "mapped") { + found++ + } + } + if found != 2 { + t.Errorf("expected 2 category mapping warnings, got %d", found) + } +} + +func TestSimulateImport_SkillOrigin(t *testing.T) { + spec := validAgentSpec() + spec.Tools = []agentspec.ToolSpec{ + {Name: "pdf-tool", SkillOrigin: "pdf-processing", InputSchema: json.RawMessage(`{}`)}, + } + result := SimulateImport(spec) + + if len(result.Definition.Tools) != 1 { + t.Fatalf("expected 1 tool, got %d", len(result.Definition.Tools)) + } + if result.Definition.Tools[0].SkillOrigin != "pdf-processing" { + t.Errorf("tool[0].SkillOrigin = %q, want %q", result.Definition.Tools[0].SkillOrigin, "pdf-processing") + } + found := false + for _, w := range result.ImportWarnings { + if contains(w, "skill_origin") && contains(w, "pdf-processing") { + found = true + } + } + if !found { + t.Errorf("expected skill_origin warning, got: %v", result.ImportWarnings) + } +} + +func TestSimulateImport_Skills(t *testing.T) { + spec := validAgentSpec() + spec.A2A = &agentspec.A2AConfig{ + Skills: []agentspec.A2ASkill{ + {ID: "skill-a", Name: "Skill A", Description: "First skill"}, + {ID: "skill-b", Name: "Skill B", Description: "Second skill"}, + }, + Capabilities: &agentspec.A2ACapabilities{Streaming: true}, + } + result := SimulateImport(spec) + + if len(result.Definition.Skills) != 2 { + t.Fatalf("expected 2 skills, got %d", len(result.Definition.Skills)) + } + if result.Definition.Skills[0] != "skill-a" { + t.Errorf("skills[0] = %q, want %q", result.Definition.Skills[0], "skill-a") + } + if result.Definition.Skills[1] != "skill-b" { + t.Errorf("skills[1] = %q, want %q", result.Definition.Skills[1], "skill-b") + } + // Should have skills count warning + found := false + for _, w := range result.ImportWarnings { + if contains(w, "a2a.skills contains 2 skills") { + found = true + } + } + if !found { + t.Errorf("expected skills count warning, got: %v", result.ImportWarnings) + } +} + +func TestSimulateImport_EgressFields(t *testing.T) { + spec := validAgentSpec() + spec.EgressProfile = "strict" + spec.EgressMode = "allowlist" + result := SimulateImport(spec) + + d := result.Definition + if d.EgressProfile != "strict" { + t.Errorf("EgressProfile = %q, want %q", d.EgressProfile, "strict") + } + if d.EgressMode != "allowlist" { + t.Errorf("EgressMode = %q, want %q", d.EgressMode, "allowlist") + } + // Should have egress warnings + profileWarning := false + modeWarning := false + for _, w := range result.ImportWarnings { + if contains(w, "egress.profile") && contains(w, "strict") { + profileWarning = true + } + if contains(w, "network_policy") { + modeWarning = true + } + } + if !profileWarning { + t.Errorf("expected egress profile warning, got: %v", result.ImportWarnings) + } + if !modeWarning { + t.Errorf("expected network_policy warning, got: %v", result.ImportWarnings) + } +} + +func TestSimulateImport_VersionFields(t *testing.T) { + spec := validAgentSpec() + spec.ToolInterfaceVersion = "1.0" + spec.SkillsSpecVersion = "agentskills-v1" + result := SimulateImport(spec) + + d := result.Definition + if d.ToolInterfaceVersion != "1.0" { + t.Errorf("ToolInterfaceVersion = %q, want %q", d.ToolInterfaceVersion, "1.0") + } + if d.SkillsSpecVersion != "agentskills-v1" { + t.Errorf("SkillsSpecVersion = %q, want %q", d.SkillsSpecVersion, "agentskills-v1") + } + // Should have version field warning + found := false + for _, w := range result.ImportWarnings { + if contains(w, "tool_interface_version") && contains(w, "compatible") { + found = true + } + } + if !found { + t.Errorf("expected tool_interface_version compatibility warning, got: %v", result.ImportWarnings) + } +} diff --git a/forge-core/validate/schema.go b/forge-core/validate/schema.go new file mode 100644 index 0000000..79fee9e --- /dev/null +++ b/forge-core/validate/schema.go @@ -0,0 +1,49 @@ +// Package validate provides JSON Schema validation for Forge specifications. +package validate + +import ( + "fmt" + "sync" + + "github.com/initializ/forge/forge-core/schemas" + "github.com/xeipuuv/gojsonschema" +) + +var ( + compiledSchema *gojsonschema.Schema + compileOnce sync.Once + compileErr error +) + +func getSchema() (*gojsonschema.Schema, error) { + compileOnce.Do(func() { + loader := gojsonschema.NewBytesLoader(schemas.AgentSpecV1Schema) + compiledSchema, compileErr = gojsonschema.NewSchema(loader) + }) + return compiledSchema, compileErr +} + +// ValidateAgentSpec validates raw JSON bytes against the AgentSpec v1.0 schema. +// It returns a slice of validation error descriptions and an error if schema +// compilation fails. +func ValidateAgentSpec(jsonData []byte) ([]string, error) { + schema, err := getSchema() + if err != nil { + return nil, fmt.Errorf("compiling agent spec schema: %w", err) + } + + result, err := schema.Validate(gojsonschema.NewBytesLoader(jsonData)) + if err != nil { + return nil, fmt.Errorf("validating agent spec: %w", err) + } + + if result.Valid() { + return nil, nil + } + + errs := make([]string, 0, len(result.Errors())) + for _, e := range result.Errors() { + errs = append(errs, e.String()) + } + return errs, nil +} diff --git a/forge-core/validate/schema_test.go b/forge-core/validate/schema_test.go new file mode 100644 index 0000000..9aa2117 --- /dev/null +++ b/forge-core/validate/schema_test.go @@ -0,0 +1,63 @@ +package validate + +import ( + "encoding/json" + "testing" +) + +func TestValidateAgentSpec_Valid(t *testing.T) { + spec := map[string]any{ + "forge_version": "1.0", + "agent_id": "test-agent", + "version": "0.1.0", + "name": "Test Agent", + } + data, err := json.Marshal(spec) + if err != nil { + t.Fatalf("json.Marshal: %v", err) + } + errs, err := ValidateAgentSpec(data) + if err != nil { + t.Fatalf("ValidateAgentSpec error: %v", err) + } + if len(errs) > 0 { + t.Errorf("expected no validation errors, got: %v", errs) + } +} + +func TestValidateAgentSpec_MissingRequired(t *testing.T) { + spec := map[string]any{ + "forge_version": "1.0", + } + data, err := json.Marshal(spec) + if err != nil { + t.Fatalf("json.Marshal: %v", err) + } + errs, err := ValidateAgentSpec(data) + if err != nil { + t.Fatalf("ValidateAgentSpec error: %v", err) + } + if len(errs) == 0 { + t.Error("expected validation errors for missing required fields") + } +} + +func TestValidateAgentSpec_InvalidAgentID(t *testing.T) { + spec := map[string]any{ + "forge_version": "1.0", + "agent_id": "INVALID_ID", + "version": "0.1.0", + "name": "Test", + } + data, err := json.Marshal(spec) + if err != nil { + t.Fatalf("json.Marshal: %v", err) + } + errs, err := ValidateAgentSpec(data) + if err != nil { + t.Fatalf("ValidateAgentSpec error: %v", err) + } + if len(errs) == 0 { + t.Error("expected validation errors for invalid agent_id pattern") + } +} diff --git a/forge-plugins/channels/markdown/markdown.go b/forge-plugins/channels/markdown/markdown.go new file mode 100644 index 0000000..f21ee2f --- /dev/null +++ b/forge-plugins/channels/markdown/markdown.go @@ -0,0 +1,256 @@ +// Package markdown converts standard markdown to platform-specific formats. +package markdown + +import ( + "regexp" + "strings" +) + +// ToTelegramHTML converts standard markdown to Telegram-compatible HTML. +func ToTelegramHTML(text string) string { + lines := strings.Split(text, "\n") + var result []string + inCodeBlock := false + var codeLang string + var codeLines []string + + for _, line := range lines { + // Check for fenced code block delimiters + if rest, ok := strings.CutPrefix(line, "```"); ok { + if !inCodeBlock { + inCodeBlock = true + codeLang = strings.TrimSpace(rest) + codeLines = nil + continue + } + // Closing code block + inCodeBlock = false + code := escapeHTML(strings.Join(codeLines, "\n")) + if codeLang != "" { + result = append(result, `
`+code+"
") + } else { + result = append(result, "
"+code+"
") + } + codeLang = "" + codeLines = nil + continue + } + + if inCodeBlock { + codeLines = append(codeLines, line) + continue + } + + // Block-level transforms on non-code lines + line = convertTelegramBlockLine(line) + result = append(result, line) + } + + // If code block was never closed, flush remaining lines as code + if inCodeBlock { + code := escapeHTML(strings.Join(codeLines, "\n")) + if codeLang != "" { + result = append(result, `
`+code+"
") + } else { + result = append(result, "
"+code+"
") + } + } + + return strings.Join(result, "\n") +} + +// convertTelegramBlockLine handles block-level elements and inline transforms for a single line. +func convertTelegramBlockLine(line string) string { + // Headers: # Header → Header + if m := headerRe.FindStringSubmatch(line); m != nil { + return "" + escapeHTML(m[2]) + "" + } + + // Blockquotes: > text →
text
+ if m := blockquoteRe.FindStringSubmatch(line); m != nil { + inner := escapeHTML(m[1]) + inner = applyTelegramInline(inner) + return "
" + inner + "
" + } + + // Bullet lists: - item or * item → • item + if m := bulletRe.FindStringSubmatch(line); m != nil { + inner := escapeHTML(m[1]) + inner = applyTelegramInline(inner) + return "• " + inner + } + + // Regular line: escape HTML, then apply inline transforms + line = escapeHTML(line) + line = applyTelegramInline(line) + return line +} + +// applyTelegramInline applies inline markdown transforms for Telegram HTML. +// Input must already be HTML-escaped. +func applyTelegramInline(line string) string { + // Inline code: `code` → code (process first to protect contents) + line = inlineCodeRe.ReplaceAllString(line, "$1") + + // Bold: **text** → text + line = boldRe.ReplaceAllString(line, "$1") + + // Strikethrough: ~~text~~ → text + line = strikethroughRe.ReplaceAllString(line, "$1") + + // Italic: *text* → text + line = italicRe.ReplaceAllString(line, "$1") + + // Links: [text](url) → text + line = linkRe.ReplaceAllString(line, `$1`) + + return line +} + +// ToSlackMrkdwn converts standard markdown to Slack mrkdwn format. +func ToSlackMrkdwn(text string) string { + lines := strings.Split(text, "\n") + var result []string + inCodeBlock := false + var codeLines []string + + for _, line := range lines { + // Check for fenced code block delimiters + if _, ok := strings.CutPrefix(line, "```"); ok { + if !inCodeBlock { + inCodeBlock = true + codeLines = nil + continue + } + // Closing code block — strip language hint + inCodeBlock = false + result = append(result, "```\n"+strings.Join(codeLines, "\n")+"\n```") + codeLines = nil + continue + } + + if inCodeBlock { + codeLines = append(codeLines, line) + continue + } + + // Block-level transforms on non-code lines + line = convertSlackBlockLine(line) + result = append(result, line) + } + + // If code block was never closed, flush remaining lines + if inCodeBlock { + result = append(result, "```\n"+strings.Join(codeLines, "\n")+"\n```") + } + + return strings.Join(result, "\n") +} + +// convertSlackBlockLine handles block-level elements and inline transforms for a single line. +func convertSlackBlockLine(line string) string { + // Headers: # Header → *Header* + if m := headerRe.FindStringSubmatch(line); m != nil { + return "*" + m[2] + "*" + } + + // Blockquotes: > text → > text (Slack supports this natively) + if m := blockquoteRe.FindStringSubmatch(line); m != nil { + inner := applySlackInline(m[1]) + return "> " + inner + } + + // Bullet lists: - item or * item → • item (avoid conflict with Slack bold *) + if m := bulletRe.FindStringSubmatch(line); m != nil { + inner := applySlackInline(m[1]) + return "• " + inner + } + + // Regular line: apply inline transforms + line = applySlackInline(line) + return line +} + +// applySlackInline applies inline markdown transforms for Slack mrkdwn. +func applySlackInline(line string) string { + // Bold: **text** → placeholder \x01text\x02 to protect from italic regex + line = boldRe.ReplaceAllStringFunc(line, func(m string) string { + inner := boldRe.FindStringSubmatch(m)[1] + return "\x01" + inner + "\x02" + }) + + // Strikethrough: ~~text~~ → ~text~ + line = strikethroughRe.ReplaceAllString(line, "~${1}~") + + // Italic: *text* → _text_ (won't match \x01..\x02 placeholders) + line = italicRe.ReplaceAllString(line, "_${1}_") + + // Restore bold placeholders → *text* + line = strings.ReplaceAll(line, "\x01", "*") + line = strings.ReplaceAll(line, "\x02", "*") + + // Links: [text](url) → + line = linkRe.ReplaceAllString(line, "<$2|$1>") + + return line +} + +// SplitMessage splits a long message into chunks that fit within limit. +// It splits at paragraph boundaries first, then newlines, then hard-splits. +func SplitMessage(text string, limit int) []string { + if len(text) <= limit { + return []string{text} + } + + var chunks []string + remaining := text + + for len(remaining) > 0 { + if len(remaining) <= limit { + chunks = append(chunks, remaining) + break + } + + chunk := remaining[:limit] + + // Try to split at paragraph boundary (\n\n) + if idx := strings.LastIndex(chunk, "\n\n"); idx > 0 { + chunks = append(chunks, remaining[:idx]) + remaining = remaining[idx+2:] + continue + } + + // Try to split at newline + if idx := strings.LastIndex(chunk, "\n"); idx > 0 { + chunks = append(chunks, remaining[:idx]) + remaining = remaining[idx+1:] + continue + } + + // Hard split at limit + chunks = append(chunks, chunk) + remaining = remaining[limit:] + } + + return chunks +} + +// escapeHTML escapes special HTML characters. +func escapeHTML(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + return s +} + +// Compiled regexes for inline markdown patterns. +var ( + headerRe = regexp.MustCompile(`^(#{1,6})\s+(.+)$`) + blockquoteRe = regexp.MustCompile(`^>\s?(.*)$`) + bulletRe = regexp.MustCompile(`^[\*\-]\s+(.+)$`) + boldRe = regexp.MustCompile(`\*\*(.+?)\*\*`) + italicRe = regexp.MustCompile(`\*(.+?)\*`) + inlineCodeRe = regexp.MustCompile("`([^`]+)`") + linkRe = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`) + strikethroughRe = regexp.MustCompile(`~~(.+?)~~`) +) diff --git a/forge-plugins/channels/markdown/markdown_test.go b/forge-plugins/channels/markdown/markdown_test.go new file mode 100644 index 0000000..14d9f4f --- /dev/null +++ b/forge-plugins/channels/markdown/markdown_test.go @@ -0,0 +1,358 @@ +package markdown + +import ( + "strings" + "testing" +) + +func TestToTelegramHTML_Bold(t *testing.T) { + got := ToTelegramHTML("this is **bold** text") + want := "this is bold text" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToTelegramHTML_Italic(t *testing.T) { + got := ToTelegramHTML("this is *italic* text") + want := "this is italic text" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToTelegramHTML_InlineCode(t *testing.T) { + got := ToTelegramHTML("use `fmt.Println` here") + want := "use fmt.Println here" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToTelegramHTML_FencedCodeBlock(t *testing.T) { + input := "```go\nfmt.Println(\"hello\")\n```" + got := ToTelegramHTML(input) + want := `
fmt.Println("hello")
` + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToTelegramHTML_FencedCodeBlockNoLang(t *testing.T) { + input := "```\nsome code\n```" + got := ToTelegramHTML(input) + want := "
some code
" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToTelegramHTML_Headers(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"# Title", "Title"}, + {"## Subtitle", "Subtitle"}, + {"### Section", "Section"}, + } + for _, tt := range tests { + got := ToTelegramHTML(tt.input) + if got != tt.want { + t.Errorf("ToTelegramHTML(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestToTelegramHTML_Links(t *testing.T) { + got := ToTelegramHTML("click [here](https://example.com)") + want := `click here` + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToTelegramHTML_Blockquote(t *testing.T) { + got := ToTelegramHTML("> this is a quote") + want := "
this is a quote
" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToTelegramHTML_Strikethrough(t *testing.T) { + got := ToTelegramHTML("this is ~~deleted~~ text") + want := "this is deleted text" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToTelegramHTML_BulletList(t *testing.T) { + input := "- first\n- second\n* third" + got := ToTelegramHTML(input) + want := "• first\n• second\n• third" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToTelegramHTML_HTMLEscaping(t *testing.T) { + got := ToTelegramHTML("use
& 5 > 3") + want := "use <div> & 5 > 3" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToTelegramHTML_NoTransformInsideCodeBlock(t *testing.T) { + input := "```\n**not bold** and *not italic*\n```" + got := ToTelegramHTML(input) + // Inside code blocks, content should be escaped but not transformed + if strings.Contains(got, "") || strings.Contains(got, "") { + t.Errorf("code block contents should not be transformed: %q", got) + } + if !strings.Contains(got, "**not bold**") { + t.Errorf("expected raw markdown preserved in code: %q", got) + } +} + +func TestToTelegramHTML_MixedContent(t *testing.T) { + input := "**Bold** and *italic* with `code`" + got := ToTelegramHTML(input) + want := "Bold and italic with code" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +// --- Slack mrkdwn tests --- + +func TestToSlackMrkdwn_Bold(t *testing.T) { + got := ToSlackMrkdwn("this is **bold** text") + want := "this is *bold* text" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToSlackMrkdwn_Italic(t *testing.T) { + got := ToSlackMrkdwn("this is *italic* text") + want := "this is _italic_ text" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToSlackMrkdwn_InlineCode(t *testing.T) { + got := ToSlackMrkdwn("use `code` here") + want := "use `code` here" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToSlackMrkdwn_FencedCodeBlock(t *testing.T) { + input := "```python\nprint('hello')\n```" + got := ToSlackMrkdwn(input) + want := "```\nprint('hello')\n```" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToSlackMrkdwn_Headers(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"# Title", "*Title*"}, + {"## Subtitle", "*Subtitle*"}, + {"### Section", "*Section*"}, + } + for _, tt := range tests { + got := ToSlackMrkdwn(tt.input) + if got != tt.want { + t.Errorf("ToSlackMrkdwn(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestToSlackMrkdwn_Links(t *testing.T) { + got := ToSlackMrkdwn("click [here](https://example.com)") + want := "click " + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToSlackMrkdwn_Blockquote(t *testing.T) { + got := ToSlackMrkdwn("> this is a quote") + want := "> this is a quote" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToSlackMrkdwn_Strikethrough(t *testing.T) { + got := ToSlackMrkdwn("this is ~~deleted~~ text") + want := "this is ~deleted~ text" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToSlackMrkdwn_BulletList(t *testing.T) { + input := "- first\n- second\n* third" + got := ToSlackMrkdwn(input) + want := "• first\n• second\n• third" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestToSlackMrkdwn_NoTransformInsideCodeBlock(t *testing.T) { + input := "```\n**not bold** and *not italic*\n```" + got := ToSlackMrkdwn(input) + if !strings.Contains(got, "**not bold**") { + t.Errorf("code block contents should not be transformed: %q", got) + } +} + +func TestToSlackMrkdwn_MixedContent(t *testing.T) { + input := "**Bold** and *italic* with `code`" + got := ToSlackMrkdwn(input) + want := "*Bold* and _italic_ with `code`" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +// --- SplitMessage tests --- + +func TestSplitMessage_Short(t *testing.T) { + chunks := SplitMessage("short message", 100) + if len(chunks) != 1 { + t.Fatalf("expected 1 chunk, got %d", len(chunks)) + } + if chunks[0] != "short message" { + t.Errorf("got %q", chunks[0]) + } +} + +func TestSplitMessage_ParagraphBoundary(t *testing.T) { + text := "first paragraph\n\nsecond paragraph" + chunks := SplitMessage(text, 20) + if len(chunks) != 2 { + t.Fatalf("expected 2 chunks, got %d: %v", len(chunks), chunks) + } + if chunks[0] != "first paragraph" { + t.Errorf("chunk[0] = %q", chunks[0]) + } + if chunks[1] != "second paragraph" { + t.Errorf("chunk[1] = %q", chunks[1]) + } +} + +func TestSplitMessage_NewlineFallback(t *testing.T) { + text := "line one\nline two\nline three" + chunks := SplitMessage(text, 15) + if len(chunks) < 2 { + t.Fatalf("expected at least 2 chunks, got %d: %v", len(chunks), chunks) + } + if chunks[0] != "line one" { + t.Errorf("chunk[0] = %q", chunks[0]) + } +} + +func TestSplitMessage_HardSplit(t *testing.T) { + text := strings.Repeat("a", 30) + chunks := SplitMessage(text, 10) + if len(chunks) != 3 { + t.Fatalf("expected 3 chunks, got %d: %v", len(chunks), chunks) + } + for _, c := range chunks { + if len(c) > 10 { + t.Errorf("chunk exceeds limit: %q (%d chars)", c, len(c)) + } + } +} + +func TestSplitMessage_ExactlyAtLimit(t *testing.T) { + text := strings.Repeat("x", 100) + chunks := SplitMessage(text, 100) + if len(chunks) != 1 { + t.Fatalf("expected 1 chunk, got %d", len(chunks)) + } +} + +// --- Real LLM output test --- + +func TestToTelegramHTML_RealLLMOutput(t *testing.T) { + input := `# Weather Report + +**Current conditions** in *San Francisco*: + +- Temperature: 65°F +- Humidity: 72% + +> Note: data from OpenWeather API + +For more info, visit [OpenWeather](https://openweathermap.org). + +` + "```json\n{\"temp\": 65, \"humidity\": 72}\n```" + + got := ToTelegramHTML(input) + + checks := []struct { + desc string + contains string + }{ + {"header converted", "Weather Report"}, + {"bold converted", "Current conditions"}, + {"italic converted", "San Francisco"}, + {"bullet list", "• Temperature: 65°F"}, + {"blockquote", "
Note: data from OpenWeather API
"}, + {"link converted", `OpenWeather`}, + {"code block", `
`},
+	}
+
+	for _, c := range checks {
+		if !strings.Contains(got, c.contains) {
+			t.Errorf("%s: expected %q in output:\n%s", c.desc, c.contains, got)
+		}
+	}
+}
+
+func TestToSlackMrkdwn_RealLLMOutput(t *testing.T) {
+	input := `# Weather Report
+
+**Current conditions** in *San Francisco*:
+
+- Temperature: 65°F
+- Humidity: 72%
+
+> Note: data from OpenWeather API
+
+For more info, visit [OpenWeather](https://openweathermap.org).`
+
+	got := ToSlackMrkdwn(input)
+
+	checks := []struct {
+		desc     string
+		contains string
+	}{
+		{"header converted", "*Weather Report*"},
+		{"bold converted", "*Current conditions*"},
+		{"italic converted", "_San Francisco_"},
+		{"bullet list", "• Temperature: 65°F"},
+		{"blockquote", "> Note: data from OpenWeather API"},
+		{"link converted", ""},
+	}
+
+	for _, c := range checks {
+		if !strings.Contains(got, c.contains) {
+			t.Errorf("%s: expected %q in output:\n%s", c.desc, c.contains, got)
+		}
+	}
+}
diff --git a/forge-plugins/channels/slack/slack.go b/forge-plugins/channels/slack/slack.go
new file mode 100644
index 0000000..9c2bad8
--- /dev/null
+++ b/forge-plugins/channels/slack/slack.go
@@ -0,0 +1,306 @@
+// Package slack implements the Slack channel plugin for the forge channel system.
+package slack
+
+import (
+	"bytes"
+	"context"
+	"crypto/hmac"
+	"crypto/sha256"
+	"encoding/hex"
+	"encoding/json"
+	"fmt"
+	"io"
+	"math"
+	"net/http"
+	"strconv"
+	"time"
+
+	"github.com/initializ/forge/forge-core/a2a"
+	"github.com/initializ/forge/forge-core/channels"
+	"github.com/initializ/forge/forge-plugins/channels/markdown"
+)
+
+const (
+	defaultWebhookPort = 3000
+	defaultWebhookPath = "/slack/events"
+	replayWindowSec    = 300 // 5 minutes
+)
+
+const slackAPIBase = "https://slack.com/api"
+
+// Plugin implements channels.ChannelPlugin for Slack.
+type Plugin struct {
+	signingSecret string
+	botToken      string
+	webhookPort   int
+	webhookPath   string
+	srv           *http.Server
+	client        *http.Client
+	apiBase       string // overridable for tests
+}
+
+// New creates an uninitialised Slack plugin.
+func New() *Plugin {
+	return &Plugin{
+		client:  &http.Client{Timeout: 30 * time.Second},
+		apiBase: slackAPIBase,
+	}
+}
+
+func (p *Plugin) Name() string { return "slack" }
+
+func (p *Plugin) Init(cfg channels.ChannelConfig) error {
+	settings := channels.ResolveEnvVars(&cfg)
+
+	p.signingSecret = settings["signing_secret"]
+	if p.signingSecret == "" {
+		return fmt.Errorf("slack: signing_secret is required (set SLACK_SIGNING_SECRET)")
+	}
+	p.botToken = settings["bot_token"]
+	if p.botToken == "" {
+		return fmt.Errorf("slack: bot_token is required (set SLACK_BOT_TOKEN)")
+	}
+
+	p.webhookPort = cfg.WebhookPort
+	if p.webhookPort == 0 {
+		p.webhookPort = defaultWebhookPort
+	}
+	p.webhookPath = cfg.WebhookPath
+	if p.webhookPath == "" {
+		p.webhookPath = defaultWebhookPath
+	}
+
+	return nil
+}
+
+func (p *Plugin) Start(ctx context.Context, handler channels.EventHandler) error {
+	mux := http.NewServeMux()
+	mux.HandleFunc(p.webhookPath, p.makeWebhookHandler(handler))
+
+	p.srv = &http.Server{
+		Addr:    fmt.Sprintf(":%d", p.webhookPort),
+		Handler: mux,
+	}
+
+	go func() {
+		<-ctx.Done()
+		p.Stop() //nolint:errcheck
+	}()
+
+	fmt.Printf("  Slack adapter listening on :%d%s\n", p.webhookPort, p.webhookPath)
+	if err := p.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+		return err
+	}
+	return nil
+}
+
+func (p *Plugin) Stop() error {
+	if p.srv != nil {
+		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+		defer cancel()
+		return p.srv.Shutdown(ctx)
+	}
+	return nil
+}
+
+func (p *Plugin) makeWebhookHandler(handler channels.EventHandler) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		body, err := io.ReadAll(r.Body)
+		if err != nil {
+			http.Error(w, "failed to read body", http.StatusBadRequest)
+			return
+		}
+
+		// Verify Slack signature
+		timestamp := r.Header.Get("X-Slack-Request-Timestamp")
+		signature := r.Header.Get("X-Slack-Signature")
+
+		if !verifySlackSignature(p.signingSecret, timestamp, body, signature) {
+			http.Error(w, "invalid signature", http.StatusUnauthorized)
+			return
+		}
+
+		// Replay protection: check timestamp within window
+		ts, err := strconv.ParseInt(timestamp, 10, 64)
+		if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > replayWindowSec {
+			http.Error(w, "request too old", http.StatusUnauthorized)
+			return
+		}
+
+		// Parse outer envelope to check type
+		var envelope struct {
+			Type      string `json:"type"`
+			Challenge string `json:"challenge"`
+		}
+		if err := json.Unmarshal(body, &envelope); err != nil {
+			http.Error(w, "invalid JSON", http.StatusBadRequest)
+			return
+		}
+
+		// Handle url_verification challenge
+		if envelope.Type == "url_verification" {
+			w.Header().Set("Content-Type", "application/json")
+			json.NewEncoder(w).Encode(map[string]string{"challenge": envelope.Challenge}) //nolint:errcheck
+			return
+		}
+
+		// Parse event callback
+		var payload slackEventPayload
+		if err := json.Unmarshal(body, &payload); err != nil {
+			http.Error(w, "invalid event payload", http.StatusBadRequest)
+			return
+		}
+
+		// Skip bot messages
+		if payload.Event.BotID != "" {
+			w.WriteHeader(http.StatusOK)
+			return
+		}
+
+		// Normalize and dispatch
+		event, err := p.NormalizeEvent(body)
+		if err != nil {
+			http.Error(w, "normalisation failed", http.StatusBadRequest)
+			return
+		}
+
+		// Acknowledge immediately (Slack requires 200 within 3s)
+		w.WriteHeader(http.StatusOK)
+
+		// Process async
+		go func() {
+			ctx := context.Background()
+			resp, err := handler(ctx, event)
+			if err != nil {
+				fmt.Printf("slack: handler error: %v\n", err)
+				return
+			}
+			if err := p.SendResponse(event, resp); err != nil {
+				fmt.Printf("slack: send response error: %v\n", err)
+			}
+		}()
+	}
+}
+
+// NormalizeEvent parses raw Slack event JSON into a ChannelEvent.
+func (p *Plugin) NormalizeEvent(raw []byte) (*channels.ChannelEvent, error) {
+	var payload slackEventPayload
+	if err := json.Unmarshal(raw, &payload); err != nil {
+		return nil, fmt.Errorf("parsing slack event: %w", err)
+	}
+
+	threadID := payload.Event.ThreadTS
+	if threadID == "" {
+		threadID = payload.Event.TS
+	}
+
+	return &channels.ChannelEvent{
+		Channel:     "slack",
+		WorkspaceID: payload.Event.Channel,
+		UserID:      payload.Event.User,
+		ThreadID:    threadID,
+		Message:     payload.Event.Text,
+		Raw:         raw,
+	}, nil
+}
+
+// SendResponse posts a message back to Slack via chat.postMessage.
+func (p *Plugin) SendResponse(event *channels.ChannelEvent, response *a2a.Message) error {
+	text := extractText(response)
+	mrkdwn := markdown.ToSlackMrkdwn(text)
+	chunks := markdown.SplitMessage(mrkdwn, 4000)
+
+	for i, chunk := range chunks {
+		payload := map[string]any{
+			"channel": event.WorkspaceID,
+			"text":    chunk,
+			"mrkdwn":  true,
+		}
+		if i == 0 {
+			payload["thread_ts"] = event.ThreadID
+		}
+		if err := p.postMessage(payload); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// postMessage posts a JSON payload to the Slack chat.postMessage API.
+func (p *Plugin) postMessage(payload map[string]any) error {
+	body, err := json.Marshal(payload)
+	if err != nil {
+		return fmt.Errorf("marshalling slack response: %w", err)
+	}
+
+	url := p.apiBase + "/chat.postMessage"
+	req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
+	if err != nil {
+		return fmt.Errorf("creating slack request: %w", err)
+	}
+	req.Header.Set("Content-Type", "application/json")
+	req.Header.Set("Authorization", "Bearer "+p.botToken)
+
+	resp, err := p.client.Do(req)
+	if err != nil {
+		return fmt.Errorf("posting to slack: %w", err)
+	}
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode != http.StatusOK {
+		respBody, _ := io.ReadAll(resp.Body)
+		return fmt.Errorf("slack API error %d: %s", resp.StatusCode, string(respBody))
+	}
+
+	return nil
+}
+
+// verifySlackSignature validates the X-Slack-Signature header using HMAC-SHA256.
+func verifySlackSignature(signingSecret, timestamp string, body []byte, signature string) bool {
+	if signingSecret == "" || timestamp == "" || signature == "" {
+		return false
+	}
+
+	baseString := fmt.Sprintf("v0:%s:%s", timestamp, body)
+	mac := hmac.New(sha256.New, []byte(signingSecret))
+	mac.Write([]byte(baseString))
+	expected := "v0=" + hex.EncodeToString(mac.Sum(nil))
+	return hmac.Equal([]byte(expected), []byte(signature))
+}
+
+// extractText concatenates all text parts from an A2A message.
+func extractText(msg *a2a.Message) string {
+	if msg == nil {
+		return "(no response)"
+	}
+	var text string
+	for _, p := range msg.Parts {
+		if p.Kind == a2a.PartKindText {
+			if text != "" {
+				text += "\n"
+			}
+			text += p.Text
+		}
+	}
+	if text == "" {
+		text = "(no text response)"
+	}
+	return text
+}
+
+// slackEventPayload represents the outer Slack event callback structure.
+type slackEventPayload struct {
+	TeamID string     `json:"team_id"`
+	Event  slackEvent `json:"event"`
+}
+
+// slackEvent represents the inner event fields we care about.
+type slackEvent struct {
+	Type     string `json:"type"`
+	Channel  string `json:"channel"`
+	User     string `json:"user"`
+	Text     string `json:"text"`
+	TS       string `json:"ts"`
+	ThreadTS string `json:"thread_ts"`
+	BotID    string `json:"bot_id"`
+}
diff --git a/forge-plugins/channels/slack/slack_test.go b/forge-plugins/channels/slack/slack_test.go
new file mode 100644
index 0000000..2be67ca
--- /dev/null
+++ b/forge-plugins/channels/slack/slack_test.go
@@ -0,0 +1,285 @@
+package slack
+
+import (
+	"context"
+	"crypto/hmac"
+	"crypto/sha256"
+	"encoding/hex"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"strconv"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/initializ/forge/forge-core/a2a"
+	"github.com/initializ/forge/forge-core/channels"
+)
+
+func TestVerifySlackSignature_Valid(t *testing.T) {
+	secret := "test-signing-secret"
+	timestamp := strconv.FormatInt(time.Now().Unix(), 10)
+	body := []byte(`{"type":"event_callback","event":{"text":"hello"}}`)
+
+	baseString := fmt.Sprintf("v0:%s:%s", timestamp, body)
+	mac := hmac.New(sha256.New, []byte(secret))
+	mac.Write([]byte(baseString))
+	sig := "v0=" + hex.EncodeToString(mac.Sum(nil))
+
+	if !verifySlackSignature(secret, timestamp, body, sig) {
+		t.Error("expected valid signature to pass")
+	}
+}
+
+func TestVerifySlackSignature_Invalid(t *testing.T) {
+	if verifySlackSignature("secret", "12345", []byte("body"), "v0=wrong") {
+		t.Error("expected invalid signature to fail")
+	}
+}
+
+func TestVerifySlackSignature_Empty(t *testing.T) {
+	if verifySlackSignature("", "", nil, "") {
+		t.Error("expected empty inputs to fail")
+	}
+}
+
+func TestNormalizeEvent(t *testing.T) {
+	raw := `{
+		"team_id": "T1234",
+		"event": {
+			"type": "message",
+			"channel": "C0123456",
+			"user": "U789",
+			"text": "hello world",
+			"ts": "1234567890.123456",
+			"thread_ts": "1234567890.000001"
+		}
+	}`
+
+	p := New()
+	event, err := p.NormalizeEvent([]byte(raw))
+	if err != nil {
+		t.Fatalf("NormalizeEvent() error: %v", err)
+	}
+
+	if event.Channel != "slack" {
+		t.Errorf("Channel = %q, want slack", event.Channel)
+	}
+	if event.WorkspaceID != "C0123456" {
+		t.Errorf("WorkspaceID = %q, want C0123456", event.WorkspaceID)
+	}
+	if event.UserID != "U789" {
+		t.Errorf("UserID = %q, want U789", event.UserID)
+	}
+	if event.ThreadID != "1234567890.000001" {
+		t.Errorf("ThreadID = %q, want 1234567890.000001", event.ThreadID)
+	}
+	if event.Message != "hello world" {
+		t.Errorf("Message = %q, want 'hello world'", event.Message)
+	}
+}
+
+func TestNormalizeEvent_NoThread(t *testing.T) {
+	raw := `{
+		"team_id": "T1234",
+		"event": {
+			"type": "message",
+			"channel": "C0123456",
+			"user": "U789",
+			"text": "top-level message",
+			"ts": "1234567890.123456"
+		}
+	}`
+
+	p := New()
+	event, err := p.NormalizeEvent([]byte(raw))
+	if err != nil {
+		t.Fatalf("NormalizeEvent() error: %v", err)
+	}
+
+	// Should fall back to ts when thread_ts is empty
+	if event.ThreadID != "1234567890.123456" {
+		t.Errorf("ThreadID = %q, want 1234567890.123456", event.ThreadID)
+	}
+}
+
+func TestURLVerificationChallenge(t *testing.T) {
+	p := New()
+	p.signingSecret = "test-secret"
+	p.botToken = "xoxb-test"
+	p.webhookPort = 0
+	p.webhookPath = "/slack/events"
+
+	timestamp := strconv.FormatInt(time.Now().Unix(), 10)
+	body := `{"type":"url_verification","challenge":"test-challenge-value"}`
+
+	sig := computeSignature("test-secret", timestamp, []byte(body))
+
+	req := httptest.NewRequest(http.MethodPost, "/slack/events", strings.NewReader(body))
+	req.Header.Set("X-Slack-Request-Timestamp", timestamp)
+	req.Header.Set("X-Slack-Signature", sig)
+
+	rr := httptest.NewRecorder()
+
+	handler := p.makeWebhookHandler(nil)
+	handler(rr, req)
+
+	if rr.Code != http.StatusOK {
+		t.Errorf("status = %d, want 200", rr.Code)
+	}
+
+	var resp map[string]string
+	if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
+		t.Fatalf("decoding response: %v", err)
+	}
+
+	if resp["challenge"] != "test-challenge-value" {
+		t.Errorf("challenge = %q, want test-challenge-value", resp["challenge"])
+	}
+}
+
+func TestBotMessageSkipped(t *testing.T) {
+	p := New()
+	p.signingSecret = "test-secret"
+	p.botToken = "xoxb-test"
+
+	timestamp := strconv.FormatInt(time.Now().Unix(), 10)
+	body := `{"type":"event_callback","event":{"type":"message","channel":"C123","user":"U123","text":"bot msg","ts":"1.1","bot_id":"B123"}}`
+
+	sig := computeSignature("test-secret", timestamp, []byte(body))
+
+	req := httptest.NewRequest(http.MethodPost, "/slack/events", strings.NewReader(body))
+	req.Header.Set("X-Slack-Request-Timestamp", timestamp)
+	req.Header.Set("X-Slack-Signature", sig)
+
+	handlerCalled := false
+	handler := p.makeWebhookHandler(func(_ context.Context, _ *channels.ChannelEvent) (*a2a.Message, error) {
+		handlerCalled = true
+		return nil, nil
+	})
+
+	rr := httptest.NewRecorder()
+	handler(rr, req)
+
+	if rr.Code != http.StatusOK {
+		t.Errorf("status = %d, want 200", rr.Code)
+	}
+	if handlerCalled {
+		t.Error("handler should not be called for bot messages")
+	}
+}
+
+func TestSendResponse(t *testing.T) {
+	// Mock Slack API
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.Header.Get("Authorization") != "Bearer xoxb-test-token" {
+			t.Errorf("Authorization = %q, want 'Bearer xoxb-test-token'", r.Header.Get("Authorization"))
+		}
+
+		body, _ := io.ReadAll(r.Body)
+		var payload map[string]any
+		json.Unmarshal(body, &payload) //nolint:errcheck
+
+		if payload["channel"] != "C0123456" {
+			t.Errorf("channel = %v, want C0123456", payload["channel"])
+		}
+		if payload["thread_ts"] != "1234567890.000001" {
+			t.Errorf("thread_ts = %v, want 1234567890.000001", payload["thread_ts"])
+		}
+		if payload["mrkdwn"] != true {
+			t.Errorf("mrkdwn = %v, want true", payload["mrkdwn"])
+		}
+
+		w.WriteHeader(http.StatusOK)
+		w.Write([]byte(`{"ok":true}`)) //nolint:errcheck
+	}))
+	defer srv.Close()
+
+	p := New()
+	p.botToken = "xoxb-test-token"
+	p.apiBase = srv.URL
+
+	event := &channels.ChannelEvent{
+		WorkspaceID: "C0123456",
+		ThreadID:    "1234567890.000001",
+	}
+
+	msg := &a2a.Message{
+		Role:  a2a.MessageRoleAgent,
+		Parts: []a2a.Part{a2a.NewTextPart("hello from agent")},
+	}
+
+	err := p.SendResponse(event, msg)
+	if err != nil {
+		t.Fatalf("SendResponse() error: %v", err)
+	}
+}
+
+func TestSendResponse_MarkdownConversion(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		body, _ := io.ReadAll(r.Body)
+		var payload map[string]any
+		json.Unmarshal(body, &payload) //nolint:errcheck
+
+		text, _ := payload["text"].(string)
+		if !strings.Contains(text, "*bold*") {
+			t.Errorf("expected *bold* in text, got %q", text)
+		}
+
+		w.WriteHeader(http.StatusOK)
+		w.Write([]byte(`{"ok":true}`)) //nolint:errcheck
+	}))
+	defer srv.Close()
+
+	p := New()
+	p.botToken = "xoxb-test-token"
+	p.apiBase = srv.URL
+
+	event := &channels.ChannelEvent{
+		WorkspaceID: "C0123456",
+		ThreadID:    "1234567890.000001",
+	}
+
+	msg := &a2a.Message{
+		Role:  a2a.MessageRoleAgent,
+		Parts: []a2a.Part{a2a.NewTextPart("this is **bold** text")},
+	}
+
+	err := p.SendResponse(event, msg)
+	if err != nil {
+		t.Fatalf("SendResponse() error: %v", err)
+	}
+}
+
+func TestExtractText(t *testing.T) {
+	tests := []struct {
+		name string
+		msg  *a2a.Message
+		want string
+	}{
+		{"nil message", nil, "(no response)"},
+		{"single text", &a2a.Message{Parts: []a2a.Part{a2a.NewTextPart("hello")}}, "hello"},
+		{"multiple text", &a2a.Message{Parts: []a2a.Part{a2a.NewTextPart("a"), a2a.NewTextPart("b")}}, "a\nb"},
+		{"no text parts", &a2a.Message{Parts: []a2a.Part{a2a.NewDataPart(42)}}, "(no text response)"},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := extractText(tt.msg)
+			if got != tt.want {
+				t.Errorf("extractText() = %q, want %q", got, tt.want)
+			}
+		})
+	}
+}
+
+// computeSignature generates a valid Slack signature for testing.
+func computeSignature(secret, timestamp string, body []byte) string {
+	baseString := fmt.Sprintf("v0:%s:%s", timestamp, body)
+	mac := hmac.New(sha256.New, []byte(secret))
+	mac.Write([]byte(baseString))
+	return "v0=" + hex.EncodeToString(mac.Sum(nil))
+}
diff --git a/forge-plugins/channels/telegram/telegram.go b/forge-plugins/channels/telegram/telegram.go
new file mode 100644
index 0000000..d272e6b
--- /dev/null
+++ b/forge-plugins/channels/telegram/telegram.go
@@ -0,0 +1,351 @@
+// Package telegram implements the Telegram channel plugin for the forge channel system.
+package telegram
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"strconv"
+	"time"
+
+	"github.com/initializ/forge/forge-core/a2a"
+	"github.com/initializ/forge/forge-core/channels"
+	"github.com/initializ/forge/forge-plugins/channels/markdown"
+)
+
+const (
+	defaultWebhookPort = 3001
+	defaultWebhookPath = "/telegram/webhook"
+	telegramAPIBase    = "https://api.telegram.org"
+	pollingTimeout     = 30 // seconds for long polling
+)
+
+// Plugin implements channels.ChannelPlugin for Telegram.
+type Plugin struct {
+	botToken    string
+	mode        string // "polling" or "webhook"
+	webhookPort int
+	webhookPath string
+	srv         *http.Server
+	client      *http.Client
+	apiBase     string // overridable for tests
+	stopCh      chan struct{}
+}
+
+// New creates an uninitialised Telegram plugin.
+func New() *Plugin {
+	return &Plugin{
+		client:  &http.Client{Timeout: 60 * time.Second},
+		apiBase: telegramAPIBase,
+		stopCh:  make(chan struct{}),
+	}
+}
+
+func (p *Plugin) Name() string { return "telegram" }
+
+func (p *Plugin) Init(cfg channels.ChannelConfig) error {
+	settings := channels.ResolveEnvVars(&cfg)
+
+	p.botToken = settings["bot_token"]
+	if p.botToken == "" {
+		return fmt.Errorf("telegram: bot_token is required (set TELEGRAM_BOT_TOKEN)")
+	}
+
+	p.mode = settings["mode"]
+	if p.mode == "" {
+		p.mode = "polling"
+	}
+	if p.mode != "polling" && p.mode != "webhook" {
+		return fmt.Errorf("telegram: mode must be 'polling' or 'webhook', got %q", p.mode)
+	}
+
+	p.webhookPort = cfg.WebhookPort
+	if p.webhookPort == 0 {
+		p.webhookPort = defaultWebhookPort
+	}
+	p.webhookPath = cfg.WebhookPath
+	if p.webhookPath == "" {
+		p.webhookPath = defaultWebhookPath
+	}
+
+	return nil
+}
+
+func (p *Plugin) Start(ctx context.Context, handler channels.EventHandler) error {
+	if p.mode == "webhook" {
+		return p.startWebhook(ctx, handler)
+	}
+	return p.startPolling(ctx, handler)
+}
+
+func (p *Plugin) Stop() error {
+	// Signal polling goroutine to stop
+	select {
+	case <-p.stopCh:
+	default:
+		close(p.stopCh)
+	}
+
+	if p.srv != nil {
+		shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+		defer cancel()
+		return p.srv.Shutdown(shutCtx)
+	}
+	return nil
+}
+
+func (p *Plugin) startWebhook(ctx context.Context, handler channels.EventHandler) error {
+	mux := http.NewServeMux()
+	mux.HandleFunc(p.webhookPath, p.makeWebhookHandler(handler))
+
+	p.srv = &http.Server{
+		Addr:    fmt.Sprintf(":%d", p.webhookPort),
+		Handler: mux,
+	}
+
+	go func() {
+		<-ctx.Done()
+		p.Stop() //nolint:errcheck
+	}()
+
+	fmt.Printf("  Telegram adapter (webhook) listening on :%d%s\n", p.webhookPort, p.webhookPath)
+	if err := p.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+		return err
+	}
+	return nil
+}
+
+func (p *Plugin) makeWebhookHandler(handler channels.EventHandler) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		body, err := io.ReadAll(r.Body)
+		if err != nil {
+			http.Error(w, "failed to read body", http.StatusBadRequest)
+			return
+		}
+
+		event, err := p.NormalizeEvent(body)
+		if err != nil {
+			http.Error(w, "invalid update", http.StatusBadRequest)
+			return
+		}
+
+		w.WriteHeader(http.StatusOK)
+
+		go func() {
+			ctx := context.Background()
+			resp, err := handler(ctx, event)
+			if err != nil {
+				fmt.Printf("telegram: handler error: %v\n", err)
+				return
+			}
+			if err := p.SendResponse(event, resp); err != nil {
+				fmt.Printf("telegram: send response error: %v\n", err)
+			}
+		}()
+	}
+}
+
+func (p *Plugin) startPolling(ctx context.Context, handler channels.EventHandler) error {
+	fmt.Printf("  Telegram adapter (polling) started\n")
+
+	var offset int64
+
+	for {
+		select {
+		case <-ctx.Done():
+			return nil
+		case <-p.stopCh:
+			return nil
+		default:
+		}
+
+		updates, err := p.getUpdates(ctx, offset)
+		if err != nil {
+			// Don't flood on errors, sleep briefly
+			select {
+			case <-ctx.Done():
+				return nil
+			case <-time.After(2 * time.Second):
+			}
+			continue
+		}
+
+		for _, update := range updates {
+			if update.UpdateID >= offset {
+				offset = update.UpdateID + 1
+			}
+
+			if update.Message == nil {
+				continue
+			}
+
+			raw, _ := json.Marshal(update)
+			event, err := p.NormalizeEvent(raw)
+			if err != nil {
+				continue
+			}
+
+			go func() {
+				resp, err := handler(ctx, event)
+				if err != nil {
+					fmt.Printf("telegram: handler error: %v\n", err)
+					return
+				}
+				if err := p.SendResponse(event, resp); err != nil {
+					fmt.Printf("telegram: send response error: %v\n", err)
+				}
+			}()
+		}
+	}
+}
+
+func (p *Plugin) getUpdates(ctx context.Context, offset int64) ([]telegramUpdate, error) {
+	url := fmt.Sprintf("%s/bot%s/getUpdates?offset=%d&timeout=%d",
+		p.apiBase, p.botToken, offset, pollingTimeout)
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	resp, err := p.client.Do(req)
+	if err != nil {
+		return nil, err
+	}
+	defer func() { _ = resp.Body.Close() }()
+
+	var result struct {
+		OK     bool             `json:"ok"`
+		Result []telegramUpdate `json:"result"`
+	}
+	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+		return nil, err
+	}
+
+	if !result.OK {
+		return nil, fmt.Errorf("telegram getUpdates failed")
+	}
+
+	return result.Result, nil
+}
+
+// NormalizeEvent parses a Telegram Update JSON into a ChannelEvent.
+func (p *Plugin) NormalizeEvent(raw []byte) (*channels.ChannelEvent, error) {
+	var update telegramUpdate
+	if err := json.Unmarshal(raw, &update); err != nil {
+		return nil, fmt.Errorf("parsing telegram update: %w", err)
+	}
+
+	if update.Message == nil {
+		return nil, fmt.Errorf("telegram update has no message")
+	}
+
+	return &channels.ChannelEvent{
+		Channel:     "telegram",
+		WorkspaceID: strconv.FormatInt(update.Message.Chat.ID, 10),
+		UserID:      strconv.FormatInt(update.Message.From.ID, 10),
+		ThreadID:    strconv.FormatInt(update.Message.MessageID, 10),
+		Message:     update.Message.Text,
+		Raw:         raw,
+	}, nil
+}
+
+// SendResponse sends a text message back to the Telegram chat.
+func (p *Plugin) SendResponse(event *channels.ChannelEvent, response *a2a.Message) error {
+	text := extractText(response)
+	html := markdown.ToTelegramHTML(text)
+	chunks := markdown.SplitMessage(html, 4096)
+
+	for i, chunk := range chunks {
+		payload := map[string]any{
+			"chat_id":    event.WorkspaceID,
+			"text":       chunk,
+			"parse_mode": "HTML",
+		}
+		if i == 0 {
+			payload["reply_to_message_id"] = event.ThreadID
+		}
+		if err := p.sendMessage(payload); err != nil {
+			// Fallback: retry without parse_mode (plain text)
+			delete(payload, "parse_mode")
+			payload["text"] = text
+			if fbErr := p.sendMessage(payload); fbErr != nil {
+				return fbErr
+			}
+		}
+	}
+	return nil
+}
+
+// sendMessage posts a JSON payload to the Telegram sendMessage API.
+func (p *Plugin) sendMessage(payload map[string]any) error {
+	body, err := json.Marshal(payload)
+	if err != nil {
+		return fmt.Errorf("marshalling telegram response: %w", err)
+	}
+
+	url := fmt.Sprintf("%s/bot%s/sendMessage", p.apiBase, p.botToken)
+	req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
+	if err != nil {
+		return fmt.Errorf("creating telegram request: %w", err)
+	}
+	req.Header.Set("Content-Type", "application/json")
+
+	resp, err := p.client.Do(req)
+	if err != nil {
+		return fmt.Errorf("posting to telegram: %w", err)
+	}
+	defer func() { _ = resp.Body.Close() }()
+
+	if resp.StatusCode != http.StatusOK {
+		respBody, _ := io.ReadAll(resp.Body)
+		return fmt.Errorf("telegram API error %d: %s", resp.StatusCode, string(respBody))
+	}
+
+	return nil
+}
+
+// extractText concatenates all text parts from an A2A message.
+func extractText(msg *a2a.Message) string {
+	if msg == nil {
+		return "(no response)"
+	}
+	var text string
+	for _, p := range msg.Parts {
+		if p.Kind == a2a.PartKindText {
+			if text != "" {
+				text += "\n"
+			}
+			text += p.Text
+		}
+	}
+	if text == "" {
+		text = "(no text response)"
+	}
+	return text
+}
+
+// Telegram API types (minimal, for parsing).
+
+type telegramUpdate struct {
+	UpdateID int64            `json:"update_id"`
+	Message  *telegramMessage `json:"message,omitempty"`
+}
+
+type telegramMessage struct {
+	MessageID int64        `json:"message_id"`
+	From      telegramUser `json:"from"`
+	Chat      telegramChat `json:"chat"`
+	Text      string       `json:"text"`
+}
+
+type telegramUser struct {
+	ID int64 `json:"id"`
+}
+
+type telegramChat struct {
+	ID int64 `json:"id"`
+}
diff --git a/forge-plugins/channels/telegram/telegram_test.go b/forge-plugins/channels/telegram/telegram_test.go
new file mode 100644
index 0000000..92b5d34
--- /dev/null
+++ b/forge-plugins/channels/telegram/telegram_test.go
@@ -0,0 +1,292 @@
+package telegram
+
+import (
+	"context"
+	"encoding/json"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"github.com/initializ/forge/forge-core/a2a"
+	"github.com/initializ/forge/forge-core/channels"
+)
+
+func TestNormalizeEvent(t *testing.T) {
+	raw := `{
+		"update_id": 100,
+		"message": {
+			"message_id": 42,
+			"from": {"id": 12345},
+			"chat": {"id": 67890},
+			"text": "hello bot"
+		}
+	}`
+
+	p := New()
+	event, err := p.NormalizeEvent([]byte(raw))
+	if err != nil {
+		t.Fatalf("NormalizeEvent() error: %v", err)
+	}
+
+	if event.Channel != "telegram" {
+		t.Errorf("Channel = %q, want telegram", event.Channel)
+	}
+	if event.WorkspaceID != "67890" {
+		t.Errorf("WorkspaceID = %q, want 67890", event.WorkspaceID)
+	}
+	if event.UserID != "12345" {
+		t.Errorf("UserID = %q, want 12345", event.UserID)
+	}
+	if event.ThreadID != "42" {
+		t.Errorf("ThreadID = %q, want 42", event.ThreadID)
+	}
+	if event.Message != "hello bot" {
+		t.Errorf("Message = %q, want 'hello bot'", event.Message)
+	}
+}
+
+func TestNormalizeEvent_NoMessage(t *testing.T) {
+	raw := `{"update_id": 100}`
+
+	p := New()
+	_, err := p.NormalizeEvent([]byte(raw))
+	if err == nil {
+		t.Fatal("expected error for update with no message")
+	}
+}
+
+func TestNormalizeEvent_InvalidJSON(t *testing.T) {
+	p := New()
+	_, err := p.NormalizeEvent([]byte("not json"))
+	if err == nil {
+		t.Fatal("expected error for invalid JSON")
+	}
+}
+
+func TestSendResponse(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		body, _ := io.ReadAll(r.Body)
+		var payload map[string]any
+		json.Unmarshal(body, &payload) //nolint:errcheck
+
+		if payload["chat_id"] != "67890" {
+			t.Errorf("chat_id = %v, want 67890", payload["chat_id"])
+		}
+		if payload["reply_to_message_id"] != "42" {
+			t.Errorf("reply_to_message_id = %v, want 42", payload["reply_to_message_id"])
+		}
+		if payload["parse_mode"] != "HTML" {
+			t.Errorf("parse_mode = %v, want HTML", payload["parse_mode"])
+		}
+
+		w.WriteHeader(http.StatusOK)
+		w.Write([]byte(`{"ok":true}`)) //nolint:errcheck
+	}))
+	defer srv.Close()
+
+	p := New()
+	p.botToken = "test-token"
+	p.apiBase = srv.URL
+
+	event := &channels.ChannelEvent{
+		WorkspaceID: "67890",
+		ThreadID:    "42",
+	}
+
+	msg := &a2a.Message{
+		Role:  a2a.MessageRoleAgent,
+		Parts: []a2a.Part{a2a.NewTextPart("agent reply")},
+	}
+
+	err := p.SendResponse(event, msg)
+	if err != nil {
+		t.Fatalf("SendResponse() error: %v", err)
+	}
+}
+
+func TestSendResponse_MarkdownConversion(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		body, _ := io.ReadAll(r.Body)
+		var payload map[string]any
+		json.Unmarshal(body, &payload) //nolint:errcheck
+
+		text, _ := payload["text"].(string)
+		if !strings.Contains(text, "bold") {
+			t.Errorf("expected bold in text, got %q", text)
+		}
+
+		w.WriteHeader(http.StatusOK)
+		w.Write([]byte(`{"ok":true}`)) //nolint:errcheck
+	}))
+	defer srv.Close()
+
+	p := New()
+	p.botToken = "test-token"
+	p.apiBase = srv.URL
+
+	event := &channels.ChannelEvent{
+		WorkspaceID: "67890",
+		ThreadID:    "42",
+	}
+
+	msg := &a2a.Message{
+		Role:  a2a.MessageRoleAgent,
+		Parts: []a2a.Part{a2a.NewTextPart("this is **bold** text")},
+	}
+
+	err := p.SendResponse(event, msg)
+	if err != nil {
+		t.Fatalf("SendResponse() error: %v", err)
+	}
+}
+
+func TestPollingGetUpdates(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		resp := `{"ok":true,"result":[{"update_id":1,"message":{"message_id":10,"from":{"id":1},"chat":{"id":2},"text":"poll msg"}}]}`
+		w.WriteHeader(http.StatusOK)
+		w.Write([]byte(resp)) //nolint:errcheck
+	}))
+	defer srv.Close()
+
+	p := New()
+	p.botToken = "test-token"
+	p.apiBase = srv.URL
+
+	updates, err := p.getUpdates(context.Background(), 0)
+	if err != nil {
+		t.Fatalf("getUpdates() error: %v", err)
+	}
+
+	if len(updates) != 1 {
+		t.Fatalf("expected 1 update, got %d", len(updates))
+	}
+
+	if updates[0].Message.Text != "poll msg" {
+		t.Errorf("message text = %q, want 'poll msg'", updates[0].Message.Text)
+	}
+}
+
+func TestWebhookHandler(t *testing.T) {
+	p := New()
+	p.botToken = "test-token"
+
+	var receivedEvent *channels.ChannelEvent
+	done := make(chan struct{})
+
+	handler := p.makeWebhookHandler(func(_ context.Context, event *channels.ChannelEvent) (*a2a.Message, error) {
+		receivedEvent = event
+		close(done)
+		return &a2a.Message{
+			Role:  a2a.MessageRoleAgent,
+			Parts: []a2a.Part{a2a.NewTextPart("ok")},
+		}, nil
+	})
+
+	body := `{"update_id":1,"message":{"message_id":10,"from":{"id":1},"chat":{"id":2},"text":"webhook msg"}}`
+	req := httptest.NewRequest(http.MethodPost, "/telegram/webhook", strings.NewReader(body))
+	rr := httptest.NewRecorder()
+
+	handler(rr, req)
+
+	if rr.Code != http.StatusOK {
+		t.Errorf("status = %d, want 200", rr.Code)
+	}
+
+	// Wait for the async goroutine (with timeout)
+	select {
+	case <-done:
+	case <-context.Background().Done():
+		t.Fatal("timed out waiting for handler")
+	}
+
+	if receivedEvent == nil {
+		t.Fatal("handler was not called")
+	}
+	if receivedEvent.Message != "webhook msg" {
+		t.Errorf("event.Message = %q, want 'webhook msg'", receivedEvent.Message)
+	}
+}
+
+func TestExtractText(t *testing.T) {
+	tests := []struct {
+		name string
+		msg  *a2a.Message
+		want string
+	}{
+		{"nil message", nil, "(no response)"},
+		{"single text", &a2a.Message{Parts: []a2a.Part{a2a.NewTextPart("hello")}}, "hello"},
+		{"multiple text", &a2a.Message{Parts: []a2a.Part{a2a.NewTextPart("a"), a2a.NewTextPart("b")}}, "a\nb"},
+		{"no text parts", &a2a.Message{Parts: []a2a.Part{a2a.NewDataPart(42)}}, "(no text response)"},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := extractText(tt.msg)
+			if got != tt.want {
+				t.Errorf("extractText() = %q, want %q", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestInit_Defaults(t *testing.T) {
+	p := New()
+
+	t.Setenv("TELEGRAM_BOT_TOKEN", "test-token-123")
+
+	cfg := channels.ChannelConfig{
+		Adapter: "telegram",
+		Settings: map[string]string{
+			"bot_token_env": "TELEGRAM_BOT_TOKEN",
+		},
+	}
+
+	err := p.Init(cfg)
+	if err != nil {
+		t.Fatalf("Init() error: %v", err)
+	}
+
+	if p.botToken != "test-token-123" {
+		t.Errorf("botToken = %q, want test-token-123", p.botToken)
+	}
+	if p.mode != "polling" {
+		t.Errorf("mode = %q, want polling", p.mode)
+	}
+	if p.webhookPort != defaultWebhookPort {
+		t.Errorf("webhookPort = %d, want %d", p.webhookPort, defaultWebhookPort)
+	}
+}
+
+func TestInit_MissingToken(t *testing.T) {
+	p := New()
+	cfg := channels.ChannelConfig{
+		Adapter:  "telegram",
+		Settings: map[string]string{},
+	}
+
+	err := p.Init(cfg)
+	if err == nil {
+		t.Fatal("expected error for missing bot_token")
+	}
+}
+
+func TestInit_InvalidMode(t *testing.T) {
+	p := New()
+
+	t.Setenv("TELEGRAM_BOT_TOKEN", "test-token")
+
+	cfg := channels.ChannelConfig{
+		Adapter: "telegram",
+		Settings: map[string]string{
+			"bot_token_env": "TELEGRAM_BOT_TOKEN",
+			"mode":          "invalid",
+		},
+	}
+
+	err := p.Init(cfg)
+	if err == nil {
+		t.Fatal("expected error for invalid mode")
+	}
+}
diff --git a/forge-plugins/go.mod b/forge-plugins/go.mod
new file mode 100644
index 0000000..68e73a0
--- /dev/null
+++ b/forge-plugins/go.mod
@@ -0,0 +1,7 @@
+module github.com/initializ/forge/forge-plugins
+
+go 1.25.0
+
+require github.com/initializ/forge/forge-core v0.0.0
+
+replace github.com/initializ/forge/forge-core => ../forge-core
diff --git a/forge-plugins/go.sum b/forge-plugins/go.sum
new file mode 100644
index 0000000..d1ac988
--- /dev/null
+++ b/forge-plugins/go.sum
@@ -0,0 +1,36 @@
+github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE=
+github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
+github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8=
+github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
+github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8=
+github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA=
+github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
+github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c=
+github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
+github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
+github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
+github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
+github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b h1:MQE+LT/ABUuuvEZ+YQAMSXindAdUh7slEmAkup74op4=
+golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/forge.yaml.example b/forge.yaml.example
new file mode 100644
index 0000000..a206fca
--- /dev/null
+++ b/forge.yaml.example
@@ -0,0 +1,50 @@
+# forge.yaml — Example agent configuration for Initializ Forge
+#
+# This file defines the agent project settings used by the `forge` CLI
+# to build, validate, and deploy A2A-compliant agent artifacts.
+
+# Unique agent identifier (lowercase alphanumeric and hyphens only)
+agent_id: my-support-agent
+
+# Semantic version of the agent
+version: 0.1.0
+
+# Framework the agent is built with (crewai, langchain, or custom)
+framework: custom
+
+# Entrypoint script or module for the agent
+entrypoint: python agent.py
+
+# Model configuration
+model:
+  provider: openai
+  name: gpt-4o
+
+# Tools available to the agent
+tools:
+  - name: web_search
+    type: builtin
+  - name: http_request
+    type: builtin
+  - name: json_parse
+    type: builtin
+
+# Skills definition file
+skills:
+  path: skills.md
+
+# Communication channels the agent supports
+channels:
+  - slack
+  - telegram
+
+# Egress security controls
+egress:
+  profile: standard
+  mode: allowlist
+  allowed_domains:
+    - api.openai.com
+    - api.perplexity.ai
+  capabilities:
+    - slack
+    - telegram
diff --git a/go.work b/go.work
new file mode 100644
index 0000000..82d8d5f
--- /dev/null
+++ b/go.work
@@ -0,0 +1,7 @@
+go 1.25.0
+
+use (
+	./forge-core
+	./forge-cli
+	./forge-plugins
+)