Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@

### Added

- **A2A 0.3.0 Agent Card conformance (issue #85, FWS-1).** Forge now
serves a spec-conformant Agent Card at the A2A 0.3.0 canonical path
`/.well-known/agent-card.json`. The card carries every required A2A
0.3.0 field — `version`, `protocolVersion` (pinned to `0.3.0`),
`defaultInputModes`, `defaultOutputModes` — plus `securitySchemes`
derived from the configured auth chain (`static_token` → HTTP
bearer, `oidc` → openIdConnect with discovery URL, `gcp_iap` → apiKey
in header, `aws_sigv4` → custom bearer format, etc.), and emits an
`agent_card_published` audit event on startup carrying the card's
identity + size + a sha256 hash so downstream consumers can detect
config drift. Identical card shape across `forge dev` and deployed
modes. See `docs/reference/a2a-agent-card.md`.
- **Workspace-level skill-builder LLM config (issue #92).** The `forge ui`
skill builder now reads its LLM configuration from
`<workspace>/.forge/ui.yaml` (or `~/.forge/ui.yaml` as a machine-wide
Expand Down Expand Up @@ -40,6 +52,14 @@
credential stomping when switching agents in the UI. Credentials are
now threaded as request-scoped values.

### Deprecated

- **Legacy Agent Card path `/.well-known/agent.json` (issue #85).** Still
served and returns the same body as the canonical
`/.well-known/agent-card.json`, but now emits a `Deprecation: true`
response header per RFC 8594 plus a `Link` header pointing at the
successor path. Scheduled for removal in the release after next.

### Fixed

- **`forge init` Custom provider now produces a runnable agent (issue #83).**
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ You write a `SKILL.md`. Forge compiles it into a secure, runnable agent with egr

| Document | Description |
|----------|-------------|
| [A2A Agent Card](docs/reference/a2a-agent-card.md) | Spec-conformant Agent Card at `/.well-known/agent-card.json` (A2A 0.3.0) |
| [Commands](docs/reference/cli-reference.md) | Full CLI reference |
| [Configuration](docs/reference/forge-yaml-schema.md) | `forge.yaml` schema and environment variables |
| [Dashboard](docs/reference/web-dashboard.md) | Web UI features and architecture |
Expand Down
2 changes: 1 addition & 1 deletion docs/core-concepts/how-forge-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ The build pipeline executes stages sequentially. Each stage lives in `forge-cli/
| # | Stage | Produces |
|---|-------|----------|
| 1 | **FrameworkAdapterStage** | Detects framework (crewai/langchain/custom), extracts agent config, generates A2A wrapper |
| 2 | **AgentSpecStage** | `agent.json` — canonical AgentSpec from ForgeConfig |
| 2 | **AgentSpecStage** | `agent.json` — canonical AgentSpec from ForgeConfig, including `a2a.skills` populated from SKILL.md frontmatter so post-build A2A clients see the agent's skill surface (see [A2A Agent Card](../reference/a2a-agent-card.md)) |
| 3 | **ToolsStage** | Tool schema files from discovered and configured tools |
| 4 | **PolicyStage** | `policy-scaffold.json` — guardrail configuration |
| 5 | **DockerfileStage** | `Dockerfile` — container image definition |
Expand Down
109 changes: 109 additions & 0 deletions docs/reference/a2a-agent-card.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# A2A Agent Card

Every Forge agent publishes an Agent Card per the [Agent2Agent (A2A) Protocol](https://github.com/google/a2a-spec) so peer agents, orchestrators (initializ platform, custom registries), and A2A-aware tooling can discover its identity, capabilities, and authentication shape via a single `GET`.

The card is JSON, conforms to **A2A 0.3.0**, and lives at the spec-canonical path.

```
GET http://<host>:<port>/.well-known/agent-card.json
```

The legacy path `GET /.well-known/agent.json` is still served and returns the same body, but emits a `Deprecation: true` response header per [RFC 8594](https://datatracker.ietf.org/doc/html/rfc8594) and a `Link` header pointing at the canonical path. The legacy alias will be removed one release after this change ships.

Both paths are public — `DefaultSkipPaths` exempts them from the auth chain.

## Card shape

A Forge agent's card always contains every field A2A 0.3.0 marks as required:

| Field | Source | Notes |
|---|---|---|
| `name` | `forge.yaml` `agent_id` (or `agentspec.Name`) | Required. |
| `description` | `agentspec.Description` | Optional but Forge populates. |
| `url` | `http://<host>:<port>` of the running A2A server | Required. |
| `version` | `forge.yaml` `version` (or `agentspec.Version`) | Required. Defaults to `0.0.0` when not set. |
| `protocolVersion` | Pinned at build time | Always `0.3.0`. Bumping is a deliberate PR. |
| `defaultInputModes` | Forge default | `["text/plain", "application/json"]`. |
| `defaultOutputModes` | Forge default | `["text/plain", "application/json"]`. |
| `skills` | `agentspec.A2A.Skills` (build-time SKILL.md mapping) + builtin tools | A2A `AgentSkill` objects; see below. |
| `capabilities` | `agentspec.A2A.Capabilities` | `streaming`, `pushNotifications`, `stateTransitionHistory`. |
| `securitySchemes` | Derived from `auth.providers` | See *Security* below. |
| `security` | Derived from `auth.providers` | First-match-wins → OR-list per A2A semantics. |

Forge-internal fields (egress allowlist, denied tools, trust hints, guardrails) are intentionally **not** serialized into the Agent Card. The card is a public discovery surface; those fields are runtime contracts that stay inside Forge.

## AgentSkill mapping

The SKILL.md frontmatter maps to A2A `AgentSkill` objects with no information loss for spec-defined fields:

| `SKILL.md` frontmatter | A2A `AgentSkill` field |
|---|---|
| `name` | `id` and `name` |
| (display name from frontmatter, if any) | `name` (overrides `id`-derived name) |
| `description` | `description` |
| `category` | `tags[0]` (so clients can group) |
| `tags` | `tags[]` (appended, case-insensitive dedup) |

A2A 0.3.0 makes `tags` **required** — Forge falls back to `["skill"]` (or `["tool"]` for builtin tools surfaced as skills) when neither category nor tags are supplied, so the field is always non-empty.

`examples`, `inputModes`, `outputModes` are spec-optional and currently not populated from SKILL.md. A future SKILL.md schema bump can add `examples:` and `modes:` blocks; the types already accept them.

### Where the skill list comes from

Forge walks SKILL.md frontmatter in **two places** — and both apply the same mapping rules above:

1. **`forge build`** — the `generate-agentspec` stage discovers `skills/*.md`, `skills/*/SKILL.md`, and the main agent skill (default `SKILL.md`, or `skills.path` from `forge.yaml`), parses each, and writes the result into `agent.json` under `a2a.skills`. This is what initializ-side registries and any consumer reading the raw `agent.json` will see.
2. **`forge run` / `forge dev`** — the runner does the same walk at agent startup and appends discovered skills onto the card. Pre-existing skills (from `agent.json`'s `a2a.skills`) take precedence; the runtime enrichment only fills gaps. This means agents started directly from source — before `forge build` runs — still publish the right skill set.

Both paths sort the discovered skills deterministically by ID so the resulting card bytes are stable across rebuilds + restarts (and so the `agent_card_published` audit event's sha256 hash is meaningful).

The card is fixed at agent startup (matches the binary's embedded skills + build artifact + runtime SKILL.md walk). Hot-reload via the file watcher re-runs the walk, rebuilds the card, and re-emits the `agent_card_published` audit event.

## Security schemes

When `forge.yaml` declares an `auth:` chain, every provider becomes one entry in `securitySchemes` and one entry in `security`. The mapping mirrors the auth middleware's actual acceptance rules:

| `auth.providers[].type` | A2A scheme | Notes |
|---|---|---|
| `static_token` | `http` + `bearer` | Shared-secret token in `Authorization`. |
| `http_verifier` | `http` + `bearer` | Opaque bearer; external verifier validates. |
| `oidc` | `openIdConnect` | `openIdConnectUrl` derived from `issuer`. |
| `azure_ad` | `openIdConnect` | `openIdConnectUrl` is the AAD per-tenant well-known. |
| `gcp_iap` | `apiKey` in `header` | `X-Goog-Iap-Jwt-Assertion`. |
| `aws_sigv4` | `http` + `bearer` (custom `bearerFormat: "forge-aws-v1"`) | Pre-signed STS URL wrapped in a Bearer. |

Schemes Forge doesn't have a well-defined mapping for emit nothing in the card — the auth chain still enforces them; the card just doesn't advertise the credential shape. Operators with a hand-wired scheme set on the card before runtime invocation are preserved verbatim (the deriver is additive).

The `security` array carries one OR-entry per scheme, matching Forge's first-match-wins chain semantics: presenting any one configured credential satisfies the requirement.

## Audit event on publish

Each time Forge finalizes an Agent Card (startup + file-watcher hot-reload), the runtime emits one `agent_card_published` audit event to the audit logger:

```json
{
"event": "agent_card_published",
"fields": {
"name": "weather-agent",
"version": "0.4.2",
"protocol_version": "0.3.0",
"url": "http://localhost:8080",
"skill_count": 7,
"capabilities": {"streaming": true, "push_notifications": false, "state_transition_history": false},
"security_schemes": ["static_token", "oidc"],
"card_size_bytes": 3471,
"card_sha256": "3a8c…"
}
}
```

The event carries identity + size metadata + a sha256 of the JSON-encoded card so audit consumers can detect config drift across deploys. Full payload bytes are intentionally NOT emitted — the same discipline every other Forge audit event respects.

## `forge dev` vs deployed parity

The card builder uses the same code path in both environments:

- `forge dev` / `forge run` from source — `AgentCardFromConfig(cfg, baseURL)` populates the core fields from `forge.yaml`; the runner then walks SKILL.md files in the workdir and appends discovered skills via `enrichAgentCardWithSkills`.
- After `forge build` produces `.forge-output/agent.json` — `AgentCardFromSpec(spec, baseURL)` populates from the spec (which already carries `a2a.skills` populated at build time); the runner's enrichment then appends any SKILL.md skills not already represented (no-op when the build artifact is complete).

Both paths apply the same `PopulateSecuritySchemes` deriver and emit the same `agent_card_published` event. **The card's JSON shape and skill list are identical in both environments** — the only difference is whether `agent.json` exists on disk; the runtime guarantees parity by walking SKILL.md regardless.
1 change: 1 addition & 0 deletions docs/security/audit-logging.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ All runtime security events are emitted as structured NDJSON to stderr with corr
| `guardrail_check` | Guardrail evaluation result |
| `auth_verify` | Inbound request authenticated successfully (with `provider`, `user_id`, `org_id`, `token_kind`) |
| `auth_fail` | Inbound request rejected (with `reason`, `token_kind`) |
| `agent_card_published` | Agent Card finalized at startup or hot-reload (with `name`, `version`, `protocol_version`, `url`, `skill_count`, `capabilities`, `security_schemes`, `card_size_bytes`, `card_sha256`). See [Agent Card reference](../reference/a2a-agent-card.md). |

### Example

Expand Down
125 changes: 125 additions & 0 deletions forge-cli/build/agentspec_skills.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package build

import (
"os"
"path/filepath"
"sort"
"strings"

cliskills "github.com/initializ/forge/forge-cli/skills"
"github.com/initializ/forge/forge-core/agentspec"
)

// populateA2ASkillsFromSKILLmd walks the agent's SKILL.md files at
// build time, parses their frontmatter, and writes the discovered
// skills into `spec.A2A.Skills`. Mirrors the runtime enrichment in
// `forge-cli/runtime/runner_agentcard_skills.go` so the published
// AgentSpec (consumed by initializ-side registries and any A2A client
// that reads `agent.json` directly) carries the same skill list the
// runtime's `/.well-known/agent-card.json` advertises.
//
// File discovery mirrors `Runner.discoverSkillFiles`:
//
// skills/*.md — flat-format skill files
// skills/*/SKILL.md — subdirectory-format skill files
// <skills.path> — main agent skill (default "SKILL.md")
//
// Skills with no `metadata.name` (or `name`) frontmatter field are
// skipped — they have no A2A-stable identity to advertise. Parsing
// failures are tolerated silently; the runner's own startup pass
// surfaces parse errors with full context.
//
// Output is deterministically ordered by ID so `agent.json` is
// byte-stable across rebuilds.
func populateA2ASkillsFromSKILLmd(spec *agentspec.AgentSpec, workDir, skillsPath string) {
skills := discoverBuildTimeSkills(workDir, skillsPath)
if len(skills) == 0 {
return
}
if spec.A2A == nil {
spec.A2A = &agentspec.A2AConfig{}
}
spec.A2A.Skills = append(spec.A2A.Skills, skills...)
sort.SliceStable(spec.A2A.Skills, func(i, j int) bool {
return spec.A2A.Skills[i].ID < spec.A2A.Skills[j].ID
})
}

// discoverBuildTimeSkills is the file-walk + parse + map sequence
// extracted for testability. Returns deduplicated A2ASkill objects
// keyed by ID; the first occurrence of each ID wins.
func discoverBuildTimeSkills(workDir, skillsPath string) []agentspec.A2ASkill {
files := discoverSkillFilePaths(workDir, skillsPath)
if len(files) == 0 {
return nil
}
seen := map[string]struct{}{}
var out []agentspec.A2ASkill
for _, p := range files {
_, meta, err := cliskills.ParseFileWithMetadata(p)
if err != nil || meta == nil {
continue
}
id := strings.TrimSpace(meta.Name)
if id == "" {
continue
}
if _, dup := seen[id]; dup {
continue
}
seen[id] = struct{}{}

// Tags: category first (so A2A clients can group skills),
// then any frontmatter tags. A2A 0.3.0 requires non-empty tags
// on AgentSkill — the runtime's a2a.AgentCard mapper defaults
// missing tags to ["skill"], so we don't have to here.
var tags []string
if cat := strings.TrimSpace(meta.Category); cat != "" {
tags = append(tags, cat)
}
tags = append(tags, meta.Tags...)

out = append(out, agentspec.A2ASkill{
ID: id,
Name: firstNonEmptyBuild(meta.Name, id),
Description: strings.TrimSpace(meta.Description),
Tags: tags,
})
}
return out
}

// discoverSkillFilePaths returns the same set of SKILL.md file paths
// the runner's `discoverSkillFiles` collects. Kept here (rather than
// shared with the runner) because the build pipeline runs without a
// Runner instance — duplicating ~15 lines beats a circular
// import or a new shared package.
func discoverSkillFilePaths(workDir, skillsPath string) []string {
skillsDir := filepath.Join(workDir, "skills")

matches, _ := filepath.Glob(filepath.Join(skillsDir, "*.md"))

subMatches, _ := filepath.Glob(filepath.Join(skillsDir, "*", "SKILL.md"))
matches = append(matches, subMatches...)

main := skillsPath
if main == "" {
main = "SKILL.md"
}
if !filepath.IsAbs(main) {
main = filepath.Join(workDir, main)
}
if info, err := os.Stat(main); err == nil && !info.IsDir() {
matches = append(matches, main)
}
return matches
}

func firstNonEmptyBuild(s ...string) string {
for _, v := range s {
if v != "" {
return v
}
}
return ""
}
Loading
Loading